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.
354 lines
9.3 KiB
354 lines
9.3 KiB
'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;
|
|
|