- 1 :
'use strict';
- 2 :
const fs = require('fs');
- 3 :
const path = require('path');
- 4 :
const jsdiff = require('diff');
- 5 :
const {SError} = require('error');
- 6 :
const GroupedQueue = require('grouped-queue');
- 7 :
- 8 :
const binaryDiff = require('./binary-diff');
- 9 :
- 10 :
class AbortedError extends SError {}
- 11 :
- 12 :
class ConflicterConflictError extends SError {}
- 13 :
- 14 :
/**
- 15 :
* The Conflicter is a module that can be used to detect conflict between files. Each
- 16 :
* Generator file system helpers pass files through this module to make sure they don't
- 17 :
* break a user file.
- 18 :
*
- 19 :
* When a potential conflict is detected, we prompt the user and ask them for
- 20 :
* confirmation before proceeding with the actual write.
- 21 :
*
- 22 :
* @constructor
- 23 :
* @property {Boolean} force - same as the constructor argument
- 24 :
*
- 25 :
* @param {TerminalAdapter} adapter - The generator adapter
- 26 :
* @param {Object} options - Conflicter options
- 27 :
* @param {Boolean} [options.force=false] - When set to true, we won't check for conflict. (the conflicter become a passthrough)
- 28 :
* @param {Boolean} [options.bail=false] - When set to true, we will abort on first conflict. (used for testing reproducibility)
- 29 :
* @param {Boolean} [options.ignoreWhitespace=false] - When set to true, whitespace changes should not generate a conflict.
- 30 :
* @param {Boolean} [options.regenerate=false] - When set to true, identical files should be written to disc.
- 31 :
* @param {Boolean} [options.dryRun=false] - When set to true, no write operation will be executed.
- 32 :
* @param {Boolean} [options.cwd=process.cwd()] - Path to be used as reference for relative path.
- 33 :
* @param {string} cwd - Set cwd for relative logs.
- 34 :
*/
- 35 :
class Conflicter {
- 36 :
constructor(adapter, options = {}) {
- 37 :
this.adapter = adapter;
- 38 :
- 39 :
this.force = options.force;
- 40 :
this.bail = options.bail;
- 41 :
this.ignoreWhitespace = options.ignoreWhitespace;
- 42 :
this.regenerate = options.regenerate;
- 43 :
this.dryRun = options.dryRun;
- 44 :
this.cwd = path.resolve(options.cwd || process.cwd());
- 45 :
- 46 :
this.diffOptions = options.diffOptions;
- 47 :
- 48 :
if (this.bail) {
- 49 :
// Bail conflicts with force option, if bail set force to false.
- 50 :
this.force = false;
- 51 :
}
- 52 :
- 53 :
this.queue = new GroupedQueue(['log', 'conflicts'], false);
- 54 :
}
- 55 :
- 56 :
log(file) {
- 57 :
const logStatus = file.conflicterLog || file.conflicter;
- 58 :
this._log(logStatus, path.relative(this.cwd, file.path));
- 59 :
}
- 60 :
- 61 :
_log(logStatus, ...args) {
- 62 :
let log;
- 63 :
if (typeof logStatus === 'function') {
- 64 :
log = logStatus;
- 65 :
} else {
- 66 :
log = this.adapter.log[logStatus];
- 67 :
if (log) {
- 68 :
log = log.bind(this.adapter.log);
- 69 :
}
- 70 :
}
- 71 :
if (log) {
- 72 :
this.queue.add('log', done => {
- 73 :
log(...args);
- 74 :
done();
- 75 :
});
- 76 :
this.queue.start();
- 77 :
}
- 78 :
}
- 79 :
- 80 :
/**
- 81 :
* Print the file differences to console
- 82 :
*
- 83 :
* @param {Object} file File object respecting this interface: { path, contents }
- 84 :
*/
- 85 :
_printDiff(file, queue = false) {
- 86 :
if (file.binary === undefined) {
- 87 :
file.binary = binaryDiff.isBinary(file.path, file.contents);
- 88 :
}
- 89 :
- 90 :
let args;
- 91 :
let logFunction;
- 92 :
if (file.binary) {
- 93 :
logFunction = this.adapter.log.writeln.bind(this.adapter.log);
- 94 :
args = [binaryDiff.diff(file.path, file.contents)];
- 95 :
} else {
- 96 :
const existing = fs.readFileSync(file.path);
- 97 :
logFunction = this.adapter.diff.bind(this.adapter);
- 98 :
args = [
- 99 :
existing.toString(),
- 100 :
(file.contents || '').toString(),
- 101 :
file.conflicterChanges
- 102 :
];
- 103 :
}
- 104 :
if (queue) {
- 105 :
this._log(logFunction, ...args);
- 106 :
} else {
- 107 :
logFunction(...args);
- 108 :
}
- 109 :
}
- 110 :
- 111 :
/**
- 112 :
* Detect conflicts between file contents at `filepath` with the `contents` passed to the
- 113 :
* function
- 114 :
*
- 115 :
* If `filepath` points to a folder, we'll always return true.
- 116 :
*
- 117 :
* Based on detect-conflict module
- 118 :
*
- 119 :
* @param {Object} file File object respecting this interface: { path, contents }
- 120 :
* @return {Boolean} `true` if there's a conflict, `false` otherwise.
- 121 :
*/
- 122 :
_detectConflict(file) {
- 123 :
let {contents} = file;
- 124 :
const filepath = path.resolve(file.path);
- 125 :
- 126 :
// If file path point to a directory, then it's not safe to write
- 127 :
if (fs.statSync(filepath).isDirectory()) {
- 128 :
return true;
- 129 :
}
- 130 :
- 131 :
if (file.binary === undefined) {
- 132 :
file.binary = binaryDiff.isBinary(file.path, file.contents);
- 133 :
}
- 134 :
- 135 :
const actual = fs.readFileSync(path.resolve(filepath));
- 136 :
- 137 :
if (!(contents instanceof Buffer)) {
- 138 :
contents = Buffer.from(contents || '', 'utf8');
- 139 :
}
- 140 :
- 141 :
if (file.binary) {
- 142 :
return actual.toString('hex') !== contents.toString('hex');
- 143 :
}
- 144 :
- 145 :
let modified;
- 146 :
let changes;
- 147 :
if (this.ignoreWhitespace) {
- 148 :
changes = jsdiff.diffWords(
- 149 :
actual.toString(),
- 150 :
contents.toString(),
- 151 :
this.diffOptions
- 152 :
);
- 153 :
modified = changes.some(change => change.value && change.value.trim() && (change.added || change.removed));
- 154 :
} else {
- 155 :
changes = jsdiff.diffLines(
- 156 :
actual.toString(),
- 157 :
contents.toString(),
- 158 :
this.diffOptions
- 159 :
);
- 160 :
modified = changes.length > 1 || changes[0].added || changes[0].removed;
- 161 :
}
- 162 :
file.conflicterChanges = changes;
- 163 :
return modified;
- 164 :
}
- 165 :
- 166 :
/**
- 167 :
* Check if a file conflict with the current version on the user disk
- 168 :
*
- 169 :
* A basic check is done to see if the file exists, if it does:
- 170 :
*
- 171 :
* 1. Read its content from `fs`
- 172 :
* 2. Compare it with the provided content
- 173 :
* 3. If identical, mark it as is and skip the check
- 174 :
* 4. If diverged, prepare and show up the file collision menu
- 175 :
*
- 176 :
* @param {Object} file - Vinyl file
- 177 :
* @return {Promise} Promise a status string ('identical', 'create',
- 178 :
* 'skip', 'force')
- 179 :
*/
- 180 :
checkForCollision(file) {
- 181 :
const rfilepath = path.relative(this.cwd, file.path);
- 182 :
if (file.conflicter) {
- 183 :
this._log(file.conflicter, rfilepath);
- 184 :
return Promise.resolve(file);
- 185 :
}
- 186 :
- 187 :
if (!fs.existsSync(file.path)) {
- 188 :
if (this.bail) {
- 189 :
this._log('writeln', 'Aborting ...');
- 190 :
return Promise.reject(ConflicterConflictError.create(`Process aborted by conflict: ${rfilepath}`));
- 191 :
}
- 192 :
- 193 :
this._log('create', rfilepath);
- 194 :
file.conflicter = this.dryRun ? 'skip' : 'create';
- 195 :
file.conflicterLog = 'create';
- 196 :
return Promise.resolve(file);
- 197 :
}
- 198 :
- 199 :
if (this.force) {
- 200 :
this._log('force', rfilepath);
- 201 :
file.conflicter = 'force';
- 202 :
return Promise.resolve(file);
- 203 :
}
- 204 :
- 205 :
if (this._detectConflict(file)) {
- 206 :
if (this.bail) {
- 207 :
this.adapter.log.conflict(rfilepath);
- 208 :
this._printDiff(file);
- 209 :
this.adapter.log.writeln('Aborting ...');
- 210 :
const error = ConflicterConflictError.create(`Process aborted by conflict: ${rfilepath}`);
- 211 :
error.file = file;
- 212 :
return Promise.reject(error);
- 213 :
}
- 214 :
- 215 :
if (this.dryRun) {
- 216 :
this._log('conflict', rfilepath);
- 217 :
this._printDiff(file, true);
- 218 :
file.conflicter = 'skip';
- 219 :
file.conflicterLog = 'conflict';
- 220 :
return Promise.resolve(file);
- 221 :
}
- 222 :
- 223 :
return new Promise((resolve, reject) => {
- 224 :
this.queue.add('conflicts', next => {
- 225 :
if (this.force) {
- 226 :
file.conflicter = 'force';
- 227 :
this.adapter.log.force(rfilepath);
- 228 :
resolve(file);
- 229 :
next();
- 230 :
return;
- 231 :
}
- 232 :
this.adapter.log.conflict(rfilepath);
- 233 :
return this._ask(file, 1).then(action => {
- 234 :
this.adapter.log[action || 'force'](rfilepath);
- 235 :
file.conflicter = action;
- 236 :
resolve(file);
- 237 :
next();
- 238 :
}).catch(reject);
- 239 :
});
- 240 :
this.queue.run();
- 241 :
});
- 242 :
}
- 243 :
this._log('identical', rfilepath);
- 244 :
if (!this.regenerate) {
- 245 :
file.conflicter = 'skip';
- 246 :
file.conflicterLog = 'identical';
- 247 :
return Promise.resolve(file);
- 248 :
}
- 249 :
- 250 :
file.conflicter = 'identical';
- 251 :
return Promise.resolve(file);
- 252 :
}
- 253 :
- 254 :
/**
- 255 :
* Actual prompting logic
- 256 :
* @private
- 257 :
* @param {Object} file vinyl file object
- 258 :
* @param {Number} counter prompts
- 259 :
*/
- 260 :
_ask(file, counter) {
- 261 :
if (file.conflicter) {
- 262 :
return Promise.resolve(file.conflicter);
- 263 :
}
- 264 :
const rfilepath = path.relative(this.cwd, file.path);
- 265 :
const prompt = {
- 266 :
name: 'action',
- 267 :
type: 'expand',
- 268 :
message: `Overwrite ${rfilepath}?`,
- 269 :
choices: [
- 270 :
{
- 271 :
key: 'y',
- 272 :
name: 'overwrite',
- 273 :
value: 'write'
- 274 :
},
- 275 :
{
- 276 :
key: 'n',
- 277 :
name: 'do not overwrite',
- 278 :
value: 'skip'
- 279 :
},
- 280 :
{
- 281 :
key: 'a',
- 282 :
name: 'overwrite this and all others',
- 283 :
value: 'force'
- 284 :
},
- 285 :
{
- 286 :
key: 'r',
- 287 :
name: 'reload file (experimental)',
- 288 :
value: 'reload'
- 289 :
},
- 290 :
{
- 291 :
key: 'x',
- 292 :
name: 'abort',
- 293 :
value: 'abort'
- 294 :
}
- 295 :
]
- 296 :
};
- 297 :
- 298 :
// Only offer diff option for files
- 299 :
if (fs.statSync(file.path).isFile()) {
- 300 :
prompt.choices.push(
- 301 :
{
- 302 :
key: 'd',
- 303 :
name: 'show the differences between the old and the new',
- 304 :
value: 'diff'
- 305 :
},
- 306 :
{
- 307 :
key: 'e',
- 308 :
name: 'edit file (experimental)',
- 309 :
value: 'edit'
- 310 :
}
- 311 :
);
- 312 :
}
- 313 :
- 314 :
return this.adapter.prompt([prompt]).then(result => {
- 315 :
if (result.action === 'abort') {
- 316 :
this.adapter.log.writeln('Aborting ...');
- 317 :
throw AbortedError.create('Process aborted by user');
- 318 :
}
- 319 :
- 320 :
if (result.action === 'diff') {
- 321 :
this._printDiff(file);
- 322 :
- 323 :
counter++;
- 324 :
if (counter === 5) {
- 325 :
throw new Error(`Recursive error ${prompt.message}`);
- 326 :
}
- 327 :
- 328 :
return this._ask(file, counter);
- 329 :
}
- 330 :
- 331 :
if (result.action === 'force') {
- 332 :
this.force = true;
- 333 :
}
- 334 :
- 335 :
if (result.action === 'write') {
- 336 :
return 'force';
- 337 :
}
- 338 :
- 339 :
if (result.action === 'reload') {
- 340 :
if (this._detectConflict(file)) {
- 341 :
return this._ask(file, counter);
- 342 :
}
- 343 :
return 'identical';
- 344 :
}
- 345 :
- 346 :
if (result.action === 'edit') {
- 347 :
return this.adapter.prompt([{
- 348 :
name: 'content',
- 349 :
type: 'editor',
- 350 :
default: file.contents.toString(),
- 351 :
postfix: `.${path.extname(file.path)}`,
- 352 :
message: `Edit ${rfilepath}`
- 353 :
}]).then(answers => {
- 354 :
file.contents = Buffer.from(answers.content || '', 'utf8');
- 355 :
if (this._detectConflict(file)) {
- 356 :
return this._ask(file, counter);
- 357 :
}
- 358 :
return 'skip';
- 359 :
});
- 360 :
}
- 361 :
- 362 :
return result.action;
- 363 :
});
- 364 :
}
- 365 :
}
- 366 :
- 367 :
module.exports = Conflicter;