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