You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
355 lines
9.3 KiB
355 lines
9.3 KiB
3 years ago
|
'use strict';
|
||
|
const fs = require('fs');
|
||
|
const path = require('path');
|
||
|
const async = require('async');
|
||
|
const jsdiff = require('diff');
|
||
|
const _ = require('lodash');
|
||
|
const typedError = require('error/typed');
|
||
|
const binaryDiff = require('./binary-diff');
|
||
|
|
||
|
const AbortedError = typedError({
|
||
|
type: 'AbortedError',
|
||
|
message: 'Process aborted by user'
|
||
|
});
|
||
|
|
||
|
const ConflictError = typedError({
|
||
|
type: 'ConflicterConflict',
|
||
|
message: 'Process aborted by conflict'
|
||
|
});
|
||
|
|
||
|
/**
|
||
|
* 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.
|
||
|
*
|
||
|
* @constructor
|
||
|
* @property {Boolean} force - same as the constructor argument
|
||
|
*
|
||
|
* @param {TerminalAdapter} adapter - The generator adapter
|
||
|
* @param {Boolean} force - When set to true, we won't check for conflict. (the
|
||
|
* conflicter become a passthrough)
|
||
|
* @param {Boolean} bail - When set to true, we will abort on first conflict. (used for
|
||
|
* testing reproducibility)
|
||
|
*/
|
||
|
class Conflicter {
|
||
|
constructor(adapter, force, options = {}) {
|
||
|
if (typeof options === 'boolean') {
|
||
|
this.bail = options;
|
||
|
} else {
|
||
|
this.bail = options.bail;
|
||
|
this.ignoreWhitespace = options.ignoreWhitespace;
|
||
|
this.skipRegenerate = options.skipRegenerate;
|
||
|
this.dryRun = options.dryRun;
|
||
|
}
|
||
|
|
||
|
this.force = force === true;
|
||
|
this.adapter = adapter;
|
||
|
this.conflicts = [];
|
||
|
|
||
|
this.diffOptions = options.diffOptions;
|
||
|
|
||
|
if (this.bail) {
|
||
|
// Set ignoreWhitespace as true by default for bail.
|
||
|
// Probably just testing, so don't override.
|
||
|
this.ignoreWhitespace = true;
|
||
|
this.skipRegenerate = true;
|
||
|
}
|
||
|
|
||
|
if (this.dryRun) {
|
||
|
// Ignore whitespace changes with "ignoreWhitespace === true" option
|
||
|
this.skipRegenerate = true;
|
||
|
}
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Add a file to conflicter queue
|
||
|
*
|
||
|
* @param {String} filepath - File destination path
|
||
|
* @param {String} contents - File new contents
|
||
|
* @param {Function} callback - callback to be called once we know if the user want to
|
||
|
* proceed or not.
|
||
|
*/
|
||
|
checkForCollision(filepath, contents, callback) {
|
||
|
if (typeof contents === 'function') {
|
||
|
const status = filepath.conflicter;
|
||
|
callback = contents;
|
||
|
contents = filepath.contents;
|
||
|
filepath = filepath.path;
|
||
|
if (status) {
|
||
|
const log = this.adapter.log[status];
|
||
|
if (log) {
|
||
|
const rfilepath = path.relative(process.cwd(), filepath);
|
||
|
log.call(this.adapter.log, rfilepath);
|
||
|
}
|
||
|
|
||
|
callback(null, status);
|
||
|
return;
|
||
|
}
|
||
|
}
|
||
|
|
||
|
this.conflicts.push({
|
||
|
file: {
|
||
|
path: path.resolve(filepath),
|
||
|
contents
|
||
|
},
|
||
|
callback
|
||
|
});
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Process the _potential conflict_ queue and ask the user to resolve conflict when they
|
||
|
* occur
|
||
|
*
|
||
|
* The user is presented with the following options:
|
||
|
*
|
||
|
* - `Y` Yes, overwrite
|
||
|
* - `n` No, do not overwrite
|
||
|
* - `a` All, overwrite this and all others
|
||
|
* - `x` Exit, abort
|
||
|
* - `d` Diff, show the differences between the old and the new
|
||
|
* - `h` Help, show this help
|
||
|
*
|
||
|
* @param {Function} cb Callback once every conflict are resolved. (note that each
|
||
|
* file can specify it's own callback. See `#checkForCollision()`)
|
||
|
*/
|
||
|
resolve(cb) {
|
||
|
cb = cb || (() => {});
|
||
|
|
||
|
const resolveConflicts = conflict => {
|
||
|
return next => {
|
||
|
if (!conflict) {
|
||
|
next();
|
||
|
return;
|
||
|
}
|
||
|
|
||
|
this.collision(conflict.file, status => {
|
||
|
// Remove the resolved conflict from the queue
|
||
|
_.pull(this.conflicts, conflict);
|
||
|
conflict.callback(null, status);
|
||
|
next();
|
||
|
});
|
||
|
};
|
||
|
};
|
||
|
|
||
|
async.series(this.conflicts.map(resolveConflicts), cb.bind(this));
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Print the file differences to console
|
||
|
*
|
||
|
* @param {Object} file File object respecting this interface: { path, contents }
|
||
|
*/
|
||
|
_printDiff(file) {
|
||
|
if (file.binary === undefined) {
|
||
|
file.binary = binaryDiff.isBinary(file.path, file.contents);
|
||
|
}
|
||
|
|
||
|
if (file.binary) {
|
||
|
this.adapter.log.writeln(binaryDiff.diff(file.path, file.contents));
|
||
|
} else {
|
||
|
const existing = fs.readFileSync(file.path);
|
||
|
this.adapter.diff(
|
||
|
existing.toString(),
|
||
|
(file.contents || '').toString(),
|
||
|
file.changes
|
||
|
);
|
||
|
}
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* 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 {Object} file File object respecting this interface: { path, contents }
|
||
|
* @return {Boolean} `true` if there's a conflict, `false` otherwise.
|
||
|
*/
|
||
|
_detectConflict(file) {
|
||
|
let contents = file.contents;
|
||
|
const filepath = path.resolve(file.path);
|
||
|
|
||
|
// If file path point to a directory, then it's not safe to write
|
||
|
if (fs.statSync(filepath).isDirectory()) return true;
|
||
|
|
||
|
if (file.binary === undefined) {
|
||
|
file.binary = binaryDiff.isBinary(file.path, file.contents);
|
||
|
}
|
||
|
|
||
|
const actual = fs.readFileSync(path.resolve(filepath));
|
||
|
|
||
|
if (!(contents instanceof Buffer)) {
|
||
|
contents = Buffer.from(contents || '', 'utf8');
|
||
|
}
|
||
|
|
||
|
if (file.binary) {
|
||
|
return actual.toString('hex') !== contents.toString('hex');
|
||
|
}
|
||
|
|
||
|
if (this.ignoreWhitespace) {
|
||
|
file.changes = jsdiff.diffWords(
|
||
|
actual.toString(),
|
||
|
contents.toString(),
|
||
|
this.diffOptions
|
||
|
);
|
||
|
} else {
|
||
|
file.changes = jsdiff.diffLines(
|
||
|
actual.toString(),
|
||
|
contents.toString(),
|
||
|
this.diffOptions
|
||
|
);
|
||
|
}
|
||
|
|
||
|
const changes = file.changes;
|
||
|
return changes.length > 1 || changes[0].added || changes[0].removed;
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* 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 {Object} file File object respecting this interface: { path, contents }
|
||
|
* @param {Function} cb Callback receiving a status string ('identical', 'create',
|
||
|
* 'skip', 'force')
|
||
|
*/
|
||
|
collision(file, cb) {
|
||
|
const rfilepath = path.relative(process.cwd(), file.path);
|
||
|
|
||
|
if (!fs.existsSync(file.path)) {
|
||
|
this.adapter.log.create(rfilepath);
|
||
|
if (this.bail) {
|
||
|
this.adapter.log.writeln('Aborting ...');
|
||
|
throw new ConflictError();
|
||
|
}
|
||
|
|
||
|
if (this.dryRun) {
|
||
|
cb('skip');
|
||
|
return;
|
||
|
}
|
||
|
|
||
|
cb('create');
|
||
|
return;
|
||
|
}
|
||
|
|
||
|
if (this.force) {
|
||
|
this.adapter.log.force(rfilepath);
|
||
|
cb('force');
|
||
|
return;
|
||
|
}
|
||
|
|
||
|
if (this._detectConflict(file)) {
|
||
|
this.adapter.log.conflict(rfilepath);
|
||
|
if (this.bail) {
|
||
|
this._printDiff(file);
|
||
|
this.adapter.log.writeln('Aborting ...');
|
||
|
throw new ConflictError();
|
||
|
}
|
||
|
|
||
|
if (this.dryRun) {
|
||
|
this._printDiff(file);
|
||
|
cb('skip');
|
||
|
return;
|
||
|
}
|
||
|
|
||
|
this._ask(file, cb, 1);
|
||
|
} else {
|
||
|
this.adapter.log.identical(rfilepath);
|
||
|
if (this.skipRegenerate) {
|
||
|
cb('skip');
|
||
|
return;
|
||
|
}
|
||
|
|
||
|
cb('identical');
|
||
|
}
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Actual prompting logic
|
||
|
* @private
|
||
|
* @param {Object} file vinyl file object
|
||
|
* @param {Function} cb callback receiving the next action
|
||
|
* @param {Number} counter prompts
|
||
|
*/
|
||
|
_ask(file, cb, counter) {
|
||
|
const rfilepath = path.relative(process.cwd(), file.path);
|
||
|
const prompt = {
|
||
|
name: 'action',
|
||
|
type: 'expand',
|
||
|
message: `Overwrite ${rfilepath}?`,
|
||
|
choices: [
|
||
|
{
|
||
|
key: 'y',
|
||
|
name: 'overwrite',
|
||
|
value: 'write'
|
||
|
},
|
||
|
{
|
||
|
key: 'n',
|
||
|
name: 'do not overwrite',
|
||
|
value: 'skip'
|
||
|
},
|
||
|
{
|
||
|
key: 'a',
|
||
|
name: 'overwrite this and all others',
|
||
|
value: 'force'
|
||
|
},
|
||
|
{
|
||
|
key: 'x',
|
||
|
name: 'abort',
|
||
|
value: 'abort'
|
||
|
}
|
||
|
]
|
||
|
};
|
||
|
|
||
|
// Only offer diff option for files
|
||
|
if (fs.statSync(file.path).isFile()) {
|
||
|
prompt.choices.push({
|
||
|
key: 'd',
|
||
|
name: 'show the differences between the old and the new',
|
||
|
value: 'diff'
|
||
|
});
|
||
|
}
|
||
|
|
||
|
this.adapter.prompt([prompt]).then(result => {
|
||
|
if (result.action === 'abort') {
|
||
|
this.adapter.log.writeln('Aborting ...');
|
||
|
throw new AbortedError();
|
||
|
}
|
||
|
|
||
|
if (result.action === 'diff') {
|
||
|
this._printDiff(file);
|
||
|
|
||
|
counter++;
|
||
|
if (counter === 5) {
|
||
|
throw new Error(`Recursive error ${prompt.message}`);
|
||
|
}
|
||
|
|
||
|
return this._ask(file, cb, counter);
|
||
|
}
|
||
|
|
||
|
if (result.action === 'force') {
|
||
|
this.force = true;
|
||
|
}
|
||
|
|
||
|
if (result.action === 'write') {
|
||
|
result.action = 'force';
|
||
|
}
|
||
|
|
||
|
this.adapter.log[result.action || 'force'](rfilepath);
|
||
|
return cb(result.action);
|
||
|
});
|
||
|
}
|
||
|
}
|
||
|
|
||
|
module.exports = Conflicter;
|