UNPKG

@yeoman/conflicter

Version:

Conflict resolution for yeoman's generator/environment stack

445 lines 16.8 kB
import fs from 'node:fs'; import { stat as fsStat, readFile } from 'node:fs/promises'; import path from 'node:path'; import process from 'node:process'; import { Buffer } from 'node:buffer'; import { diffLines, diffWords } from 'diff'; import { loadFile } from 'mem-fs'; import { clearFileState, setModifiedFileState } from 'mem-fs-editor/state'; import { transform } from 'p-transform'; import { binaryDiff, isBinary } from './binary-diff.js'; const statusToSkipFile = [ 'skip', /** Skip file and print diff */ 'diff', /** Skip file and add to .yo-resolve */ 'ignore', ]; const fileShouldBeSkipped = (action) => statusToSkipFile.includes(action); export function setConflicterStatus(file, status) { file.conflicter = status; return file; } const prepareChange = (changes, prefix) => changes .split('\n') .map((line, index, array) => (array.length - 1 === index ? line : `${prefix}${line}`)) .join('\n'); /** * The Conflicter is a module that can be used to detect conflict between files. Each * Generator file system helpers pass files through this module to make sure they don't * break a user file. * * When a potential conflict is detected, we prompt the user and ask them for * confirmation before proceeding with the actual write. */ export class Conflicter { adapter; force; bail; ignoreWhitespace; regenerate; dryRun; cwd; diffOptions; customizeActions; constructor(adapter, options) { this.adapter = adapter; this.force = options?.force ?? false; this.bail = options?.bail ?? false; this.ignoreWhitespace = options?.ignoreWhitespace ?? false; this.regenerate = options?.regenerate ?? false; this.dryRun = options?.dryRun ?? false; this.cwd = path.resolve(options?.cwd ?? process.cwd()); this.diffOptions = options?.diffOptions; this.customizeActions = options?.customizeActions ?? (actions => actions); if (this.bail) { // Bail conflicts with force option, if bail set force to false. this.force = false; } } log(file, adapter = this.adapter) { const logStatus = file.conflicter; if (logStatus) { const logLevel = fileShouldBeSkipped(logStatus) ? 'skip' : logStatus; if (adapter.log[logLevel]) { adapter.log[logLevel](file.relativePath); } } } /** * Print the file differences to console * * @param {Object} file File object respecting this interface: { path, contents } */ async _printDiff({ file, adapter }) { const destinationAdapter = adapter ?? this.adapter; if (file.binary === undefined) { file.binary = isBinary(file.path, file.contents ?? undefined); } if (file.binary) { destinationAdapter.log.writeln(binaryDiff(file.path, file.contents ?? undefined)); return; } const colorLines = (colored) => { if (colored.color) { const lines = colored.message.split('\n'); const returnValue = []; for (const [index, message] of lines.entries()) { // Empty message can be ignored if (message) { returnValue.push({ message, color: colored.color }); } if (index + 1 < lines.length) { returnValue.push({ message: '\n' }); } } return returnValue; } return [colored]; }; const messages = file.conflicterChanges ?.map((change) => { if (change.added) { return { color: 'added', message: prepareChange(change.value, '+') }; } if (change.removed) { return { color: 'removed', message: prepareChange(change.value, '-') }; } return { message: prepareChange(change.value, ' ') }; }) .map((colored) => colorLines(colored)); if (file.fileModeChanges) { destinationAdapter.log.colored([ { message: `\nold mode ${file.fileModeChanges[0]}`, color: 'removed' }, { message: `\nnew mode ${file.fileModeChanges[1]}`, color: 'added' }, { message: '\n' }, ]); } if (messages) { destinationAdapter.log.colored([ { message: '\n' }, { message: 'removed', color: 'removed' }, { message: '' }, { message: 'added', color: 'added' }, { message: '\n\n' }, ...messages.flat(), { message: '\n\n' }, ]); } } /** * Detect conflicts between file contents at `filepath` with the `contents` passed to the * function * * If `filepath` points to a folder, we'll always return true. * * Based on detect-conflict module * * @param {import('vinyl')} file File object respecting this interface: { path, contents } * @return {Boolean} `true` if there's a conflict, `false` otherwise. */ async _detectConflict(file) { let { contents } = file; const { stat } = file; const filepath = path.resolve(file.path); // If file path point to a directory, then it's not safe to write const diskStat = await fsStat(filepath); if (diskStat.isDirectory()) { return true; } if (stat?.mode && diskStat.mode !== stat.mode) { file.fileModeChanges = [Number.parseInt(diskStat.mode.toString(8), 10), Number.parseInt(stat.mode.toString(8), 10)]; } if (file.binary === undefined) { file.binary = isBinary(file.path, file.contents ?? undefined); } const diskContents = await readFile(path.resolve(filepath)); if (!Buffer.isBuffer(contents)) { contents = Buffer.from(contents ?? '', 'utf8'); } if (file.binary) { return Boolean(file.fileModeChanges) || diskContents.toString('hex') !== contents.toString('hex'); } let modified; let changes; if (this.ignoreWhitespace) { changes = diffWords(diskContents.toString(), contents.toString(), this.diffOptions); modified = changes.some(change => change.value?.trim() && (change.added || change.removed)); } else { changes = diffLines(diskContents.toString(), contents.toString(), this.diffOptions); modified = (changes && changes.length > 0 && (changes.length > 1 || changes[0].added || changes[0].removed)) ?? false; } if (modified) { file.conflicterChanges = changes; file.conflicterData = { diskContents }; } return Boolean(file.fileModeChanges) || modified; } /** * Check if a file conflict with the current version on the user disk * * A basic check is done to see if the file exists, if it does: * * 1. Read its content from `fs` * 2. Compare it with the provided content * 3. If identical, mark it as is and skip the check * 4. If diverged, prepare and show up the file collision menu * * @param file - Vinyl file * @return Promise the Vinyl file */ async checkForCollision(file) { file.relativePath = path.relative(this.cwd, file.path); if (!file.conflicter) { file = await this._checkForCollision(file); } if (file.conflicter === 'conflict' && !this.bail && !this.dryRun) { const conflictedFile = file; if (this.adapter.queue) { const queuedFile = await this.adapter.queue(async (adapter) => { const file = await this.ask(adapter, conflictedFile); this.log(file, adapter); return file; }); /* c8 ignore next 3 */ if (!queuedFile) { throw new Error('A conflicter file was not returned'); } file = queuedFile; } else { /* c8 ignore next 3 */ file = await this.ask(this.adapter, conflictedFile); this.log(file); } } else { this.log(file); } if (file.changesDetected && this.bail) { if (file.conflicterChanges) { await this._printDiff({ file: file }); } this.adapter.log.writeln('Aborting ...'); const error = new Error(`Process aborted by conflict: ${file.relativePath}`); error.file = file; throw error; } if (this.dryRun) { if (file.conflicterChanges) { await this._printDiff({ file: file }); } setConflicterStatus(file, 'skip'); } if (!this.regenerate && file.conflicter === 'identical') { setConflicterStatus(file, 'skip'); } return file; } async _checkForCollision(file) { if (!fs.existsSync(file.path)) { file.changesDetected = true; setConflicterStatus(file, 'create'); return file; } if (this.force) { setConflicterStatus(file, 'force'); return file; } if (await this._detectConflict(file)) { file.changesDetected = true; setConflicterStatus(file, 'conflict'); return file; } setConflicterStatus(file, 'identical'); return file; } async ask(adapter, file) { if (this.force) { setConflicterStatus(file, 'force'); return file; } adapter.log.conflict(file.relativePath); const action = await this._ask({ file, counter: 1, adapter }); setConflicterStatus(file, action); return file; } /** * Actual prompting logic * @private * @param {import('vinyl')} file vinyl file object * @param {Number} counter prompts */ async _ask({ file, counter, adapter, }) { // Only offer diff option for files const fileStat = await fsStat(file.path); const message = `Overwrite ${file.relativePath}?`; const { separator } = adapter; const result = await adapter.prompt([ { name: 'action', type: 'expand', message, choices: this.customizeActions([ { key: 'y', name: 'overwrite', value: 'write', }, { key: 'n', name: 'do not overwrite', value: 'skip', }, { key: 'a', name: 'overwrite this and all others', value: 'force', }, ...(fileStat.isFile() ? [ { key: 'd', name: 'show the differences between the old and the new', value: 'diff', }, ] : []), { key: 'x', name: 'abort', value: 'abort', }, ...(separator ? [separator()] : []), ...(fileStat.isFile() ? [ { key: 'r', name: 'reload file (experimental)', value: 'reload', }, { key: 'e', name: 'edit file (experimental)', value: 'edit', }, { key: 'i', name: 'ignore, do not overwrite and remember (experimental)', value: 'ignore', }, ] : []), ], { separator }), }, ]); let { action } = result; if (typeof action === 'function') { action = await action.call(this, { file, relativeFilePath: file.relativePath, adapter }); } if (action === 'abort') { adapter.log.writeln('Aborting ...'); throw new Error('Process aborted by user'); } if (action === 'diff') { await this._printDiff({ file, adapter }); counter++; if (counter === 5) { throw new Error(`Recursive error ${message}`); } return this._ask({ file, counter, adapter }); } if (action === 'force') { this.force = true; return 'force'; } if (action === 'write') { return 'force'; } if (action === 'reload') { if (await this._detectConflict(file)) { action = 'ask'; } else { return 'identical'; } } if (action === 'edit') { const answers = await adapter.prompt([ { name: 'content', type: 'editor', default: file.contents?.toString(), postfix: `.${path.extname(file.path)}`, message: `Edit ${file.relativePath}`, }, ]); file.contents = Buffer.from(answers.content ?? '', 'utf8'); if (await this._detectConflict(file)) { action = 'ask'; } else { return 'skip'; } } if (action === 'ask') { return this._ask({ file, counter, adapter }); } if (!['skip', 'ignore'].includes(action)) { this.adapter.log.info(`Unknown conflicater action: ${result.action}`); } return action; } createTransform({ yoResolveFileName } = {}) { const yoResolveFilePath = path.resolve(this.cwd, yoResolveFileName ?? '.yo-resolve'); let yoResolveFile; let yoResolveContents = ''; return transform(async (file) => { const conflicterFile = await this.checkForCollision(file); const action = conflicterFile.conflicter; delete conflicterFile.conflicter; delete conflicterFile.changesDetected; delete conflicterFile.binary; delete conflicterFile.conflicterChanges; delete conflicterFile.fileModeChanges; if (action) { if (action === 'ignore') { yoResolveContents += `${file.relativePath} skip\n`; } else if (action === 'diff') { try { const stat = await fsStat(file.path); if (stat.isFile()) { await this._printDiff({ file: conflicterFile }); } } catch { // ignore } } if (fileShouldBeSkipped(action)) { clearFileState(conflicterFile); } } if (file.path === yoResolveFilePath) { yoResolveFile = file; return; } return conflicterFile; }, function () { if (yoResolveContents) { yoResolveFile ??= loadFile(yoResolveFilePath); setModifiedFileState(yoResolveFile); const oldContents = yoResolveFile.contents?.toString() ?? ''; yoResolveFile.contents = Buffer.from(oldContents + yoResolveContents); this.push(yoResolveFile); } else if (yoResolveFile) { this.push(yoResolveFile); } }); } } export const createConflicterTransform = (adapter, { yoResolveFileName, ...conflicterOptions } = {}) => new Conflicter(adapter, conflicterOptions).createTransform({ yoResolveFileName }); //# sourceMappingURL=conflicter.js.map