'use strict';
const fs = require('fs');
const path = require('path');
const jsdiff = require('diff');
const {SError} = require('error');
const GroupedQueue = require('grouped-queue');
const binaryDiff = require('./binary-diff');
class AbortedError extends SError {}
class ConflicterConflictError extends SError {}
/**
* 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 {Object} options - Conflicter options
* @param {Boolean} [options.force=false] - When set to true, we won't check for conflict. (the conflicter become a passthrough)
* @param {Boolean} [options.bail=false] - When set to true, we will abort on first conflict. (used for testing reproducibility)
* @param {Boolean} [options.ignoreWhitespace=false] - When set to true, whitespace changes should not generate a conflict.
* @param {Boolean} [options.regenerate=false] - When set to true, identical files should be written to disc.
* @param {Boolean} [options.dryRun=false] - When set to true, no write operation will be executed.
* @param {Boolean} [options.cwd=process.cwd()] - Path to be used as reference for relative path.
* @param {string} cwd - Set cwd for relative logs.
*/
class Conflicter {
constructor(adapter, options = {}) {
this.adapter = adapter;
this.force = options.force;
this.bail = options.bail;
this.ignoreWhitespace = options.ignoreWhitespace;
this.regenerate = options.regenerate;
this.dryRun = options.dryRun;
this.cwd = path.resolve(options.cwd || process.cwd());
this.diffOptions = options.diffOptions;
if (this.bail) {
// Bail conflicts with force option, if bail set force to false.
this.force = false;
}
this.queue = new GroupedQueue(['log', 'conflicts'], false);
}
log(file) {
const logStatus = file.conflicterLog || file.conflicter;
this._log(logStatus, path.relative(this.cwd, file.path));
}
_log(logStatus, ...args) {
let log;
if (typeof logStatus === 'function') {
log = logStatus;
} else {
log = this.adapter.log[logStatus];
if (log) {
log = log.bind(this.adapter.log);
}
}
if (log) {
this.queue.add('log', done => {
log(...args);
done();
});
this.queue.start();
}
}
/**
* Print the file differences to console
*
* @param {Object} file File object respecting this interface: { path, contents }
*/
_printDiff(file, queue = false) {
if (file.binary === undefined) {
file.binary = binaryDiff.isBinary(file.path, file.contents);
}
let args;
let logFunction;
if (file.binary) {
logFunction = this.adapter.log.writeln.bind(this.adapter.log);
args = [binaryDiff.diff(file.path, file.contents)];
} else {
const existing = fs.readFileSync(file.path);
logFunction = this.adapter.diff.bind(this.adapter);
args = [
existing.toString(),
(file.contents || '').toString(),
file.conflicterChanges
];
}
if (queue) {
this._log(logFunction, ...args);
} else {
logFunction(...args);
}
}
/**
* 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;
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');
}
let modified;
let changes;
if (this.ignoreWhitespace) {
changes = jsdiff.diffWords(
actual.toString(),
contents.toString(),
this.diffOptions
);
modified = changes.some(change => change.value && change.value.trim() && (change.added || change.removed));
} else {
changes = jsdiff.diffLines(
actual.toString(),
contents.toString(),
this.diffOptions
);
modified = changes.length > 1 || changes[0].added || changes[0].removed;
}
file.conflicterChanges = changes;
return 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 {Object} file - Vinyl file
* @return {Promise} Promise a status string ('identical', 'create',
* 'skip', 'force')
*/
checkForCollision(file) {
const rfilepath = path.relative(this.cwd, file.path);
if (file.conflicter) {
this._log(file.conflicter, rfilepath);
return Promise.resolve(file);
}
if (!fs.existsSync(file.path)) {
if (this.bail) {
this._log('writeln', 'Aborting ...');
return Promise.reject(ConflicterConflictError.create(`Process aborted by conflict: ${rfilepath}`));
}
this._log('create', rfilepath);
file.conflicter = this.dryRun ? 'skip' : 'create';
file.conflicterLog = 'create';
return Promise.resolve(file);
}
if (this.force) {
this._log('force', rfilepath);
file.conflicter = 'force';
return Promise.resolve(file);
}
if (this._detectConflict(file)) {
if (this.bail) {
this.adapter.log.conflict(rfilepath);
this._printDiff(file);
this.adapter.log.writeln('Aborting ...');
const error = ConflicterConflictError.create(`Process aborted by conflict: ${rfilepath}`);
error.file = file;
return Promise.reject(error);
}
if (this.dryRun) {
this._log('conflict', rfilepath);
this._printDiff(file, true);
file.conflicter = 'skip';
file.conflicterLog = 'conflict';
return Promise.resolve(file);
}
return new Promise((resolve, reject) => {
this.queue.add('conflicts', next => {
if (this.force) {
file.conflicter = 'force';
this.adapter.log.force(rfilepath);
resolve(file);
next();
return;
}
this.adapter.log.conflict(rfilepath);
return this._ask(file, 1).then(action => {
this.adapter.log[action || 'force'](rfilepath);
file.conflicter = action;
resolve(file);
next();
}).catch(reject);
});
this.queue.run();
});
}
this._log('identical', rfilepath);
if (!this.regenerate) {
file.conflicter = 'skip';
file.conflicterLog = 'identical';
return Promise.resolve(file);
}
file.conflicter = 'identical';
return Promise.resolve(file);
}
/**
* Actual prompting logic
* @private
* @param {Object} file vinyl file object
* @param {Number} counter prompts
*/
_ask(file, counter) {
if (file.conflicter) {
return Promise.resolve(file.conflicter);
}
const rfilepath = path.relative(this.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: 'r',
name: 'reload file (experimental)',
value: 'reload'
},
{
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'
},
{
key: 'e',
name: 'edit file (experimental)',
value: 'edit'
}
);
}
return this.adapter.prompt([prompt]).then(result => {
if (result.action === 'abort') {
this.adapter.log.writeln('Aborting ...');
throw AbortedError.create('Process aborted by user');
}
if (result.action === 'diff') {
this._printDiff(file);
counter++;
if (counter === 5) {
throw new Error(`Recursive error ${prompt.message}`);
}
return this._ask(file, counter);
}
if (result.action === 'force') {
this.force = true;
}
if (result.action === 'write') {
return 'force';
}
if (result.action === 'reload') {
if (this._detectConflict(file)) {
return this._ask(file, counter);
}
return 'identical';
}
if (result.action === 'edit') {
return this.adapter.prompt([{
name: 'content',
type: 'editor',
default: file.contents.toString(),
postfix: `.${path.extname(file.path)}`,
message: `Edit ${rfilepath}`
}]).then(answers => {
file.contents = Buffer.from(answers.content || '', 'utf8');
if (this._detectConflict(file)) {
return this._ask(file, counter);
}
return 'skip';
});
}
return result.action;
});
}
}
module.exports = Conflicter;