@yeoman/conflicter
Version:
Conflict resolution for yeoman's generator/environment stack
445 lines • 16.8 kB
JavaScript
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