diff --git a/lib/cli-run.js b/lib/cli-run.js new file mode 100644 index 0000000..5fe0ae9 --- /dev/null +++ b/lib/cli-run.js @@ -0,0 +1,5 @@ +#!/usr/bin/env node + +const { main } = require('./cli'); + +process.exit(main()); diff --git a/lib/cli.js b/lib/cli.js new file mode 100644 index 0000000..3ea12be --- /dev/null +++ b/lib/cli.js @@ -0,0 +1,102 @@ +const fs = require('fs'); +const parseConflictJSON = require('.'); +const { isDiff } = parseConflictJSON; +const { version, description } = require('../package.json'); + + +const help = `\ +usage: npx parse-conflict-json [OPTIONS...] [--] [FILE]... + +Parse JSON file(s) and resolve merge conflicts if possible. + +options: + -h, --help print this help and exit + -v, --version print version number and exit + -i, --in-place write resolved conflicts back to FILE (default to stdout) + -p, --prefer {ours|theirs} + prefer *our* or *their* version of the file, respectively + (default: 'ours') + +Exits 0 on success, 1 on error.\ +`; + + +function parseArgs(argv = process.argv.slice(2)) { + const argSepIdx = argv.findIndex(x => x == '--'); + const opts = argSepIdx >= 0 ? argv.slice(0, argSepIdx) : argv; + const args = argSepIdx >= 0 ? argv.slice(argSepIdx + 1) : []; + + if (opts.find(arg => arg === "-h" || arg === "--help")) { + console.log(help); + return; + } + + const options = { + inPlace: false, + prefer: '', + files: (args.slice() || []), + }; + + for (let i = 0, len = opts.length; i < len; i++) { + const [opt, ...maybeArg] = opts[i].split('=', 1); + switch (opt) { + case '-v': + case '--version': + console.log(version); + return; + case '-i': + case '--in-place': + if (maybeArg.length) { + throw new Error(`option ${opt} does not take an argument`); + } + options.inPlace = true; + case '-p': + case '--prefer': + const arg = maybeArg.length ? maybeArg[0] : opts[++i]; + options.prefer = arg; // error will be thrown by index.js + default: + if (args.length || opt.startsWith('-')) { + throw new Error(`unrecognized option ${opt}`) + } else { + options.files.push(opt); + } + } + } + + return options; +} + +function main(argv = process.argv.slice(2)) { + try { + const options = parseArgs(argv); + if (!options) { return 0; } + + if (!options.files.length) { + throw Error("No files specified!"); + } + + for (const file of options.files) { + const contents = fs.readFileSync(file).toString(); + + if (!isDiff(contents)) { + console.error(`warning: file {file} does not have conflict markers`); + } + + const resolved = parseConflictJSON(contents, null, options.prefer); + + if (options.inPlace) { + fs.writeFileSync(file, resolved); + console.error(`${file}: wrote ${Buffer.byteLength(resolved, 'utf8')} bytes`) + } else { + console.log(resolved); + } + } + return 0; + + } catch (err) { + console.error(`${err}`); + return 1; + } +} + +module.exports = { main, parseArgs }; diff --git a/package.json b/package.json index fc223b6..434b4dc 100644 --- a/package.json +++ b/package.json @@ -5,6 +5,7 @@ "author": "GitHub Inc.", "license": "ISC", "main": "lib", + "bin": "lib/cli-run.js", "scripts": { "test": "tap", "snap": "tap", @@ -14,17 +15,10 @@ "posttest": "npm run lint", "template-oss-apply": "template-oss-apply --force" }, - "tap": { - "check-coverage": true, - "nyc-arg": [ - "--exclude", - "tap-snapshots/**" - ] - }, "devDependencies": { "@npmcli/eslint-config": "^4.0.0", "@npmcli/template-oss": "4.21.4", - "tap": "^16.0.1" + "tap": "^18.7.2" }, "dependencies": { "json-parse-even-better-errors": "^3.0.0", diff --git a/tap-snapshots/test/basic.js.test.cjs b/tap-snapshots/test/basic.js.test.cjs index 7517f72..1021d04 100644 --- a/tap-snapshots/test/basic.js.test.cjs +++ b/tap-snapshots/test/basic.js.test.cjs @@ -5,13 +5,15 @@ * Make sure to inspect the output below. Do not ignore changes! */ 'use strict' -exports[`test/basic.js TAP basic usage > parse unconflicted 1`] = ` +exports[`test/basic.js > TAP > basic usage > parse unconflicted 1`] = ` Object { "a": "apple", + [Symbol.for(indent)]: "", + [Symbol.for(newline)]: "", } ` -exports[`test/basic.js TAP conflicted > parse conflicted, preferring theirs 1`] = ` +exports[`test/basic.js > TAP > conflicted > parse conflicted, preferring theirs 1`] = ` Object { "a": Object { "b": Object { @@ -32,10 +34,12 @@ Object { }, 1, ], + [Symbol.for(indent)]: "", + [Symbol.for(newline)]: "", } ` -exports[`test/basic.js TAP conflicted > prefer theirs 1`] = ` +exports[`test/basic.js > TAP > conflicted > prefer theirs 1`] = ` Object { "a": Object { "b": Object { @@ -54,10 +58,12 @@ Object { }, 1, ], + [Symbol.for(indent)]: "", + [Symbol.for(newline)]: "", } ` -exports[`test/basic.js TAP error states > BOM is no problem 1`] = ` +exports[`test/basic.js > TAP > error states > BOM is no problem 1`] = ` Object { "a": Object { "b": Object { @@ -78,13 +84,17 @@ Object { }, 1, ], + [Symbol.for(indent)]: "", + [Symbol.for(newline)]: "", } ` -exports[`test/basic.js TAP global object attributes > filters out global object attributes 1`] = ` +exports[`test/basic.js > TAP > global object attributes > filters out global object attributes 1`] = ` Object { "__proto__": "__proto__", "constructor": "constructor", + [Symbol.for(indent)]: "", + [Symbol.for(newline)]: "", "toString": "toString", "x": Object {}, } diff --git a/tap-snapshots/test/cli.js.test.cjs b/tap-snapshots/test/cli.js.test.cjs new file mode 100644 index 0000000..a62075c --- /dev/null +++ b/tap-snapshots/test/cli.js.test.cjs @@ -0,0 +1,438 @@ +/* IMPORTANT + * This snapshot file is auto-generated, but designed for humans. + * It should be checked into source control and tracked carefully. + * Re-generate by setting TAP_SNAPSHOT=1 and running tests. + * Make sure to inspect the output below. Do not ignore changes! + */ +'use strict' +exports[`test/cli.js > TAP > --help arg > exit code 1`] = ` +0 +` + +exports[`test/cli.js > TAP > --help arg > stderr 1`] = ` +Array [] +` + +exports[`test/cli.js > TAP > --help arg > stdout 1`] = ` +Array [ + Array [ + String( + usage: npx parse-conflict-json [OPTIONS...] [--] [FILE]... + + Parse JSON file(s) and resolve merge conflicts if possible. + + options: + -h, --help print this help and exit + -v, --version print version number and exit + -i, --in-place write resolved conflicts back to FILE (default to stdout) + -p, --prefer {ours|theirs} + prefer *our* or *their* version of the file, respectively + (default: 'ours') + + Exits 0 on success, 1 on error. + ), + ], +] +` + +exports[`test/cli.js > TAP > --help arg after other stuff > exit code 1`] = ` +0 +` + +exports[`test/cli.js > TAP > --help arg after other stuff > stderr 1`] = ` +Array [] +` + +exports[`test/cli.js > TAP > --help arg after other stuff > stdout 1`] = ` +Array [ + Array [ + String( + usage: npx parse-conflict-json [OPTIONS...] [--] [FILE]... + + Parse JSON file(s) and resolve merge conflicts if possible. + + options: + -h, --help print this help and exit + -v, --version print version number and exit + -i, --in-place write resolved conflicts back to FILE (default to stdout) + -p, --prefer {ours|theirs} + prefer *our* or *their* version of the file, respectively + (default: 'ours') + + Exits 0 on success, 1 on error. + ), + ], +] +` + +exports[`test/cli.js > TAP > --version arg > exit code 1`] = ` +0 +` + +exports[`test/cli.js > TAP > --version arg > stderr 1`] = ` +Array [] +` + +exports[`test/cli.js > TAP > --version arg > stdout 1`] = ` +Array [ + Array [ + "{VERSION}", + ], +] +` + +exports[`test/cli.js > TAP > -h arg > exit code 1`] = ` +0 +` + +exports[`test/cli.js > TAP > -h arg > stderr 1`] = ` +Array [] +` + +exports[`test/cli.js > TAP > -h arg > stdout 1`] = ` +Array [ + Array [ + String( + usage: npx parse-conflict-json [OPTIONS...] [--] [FILE]... + + Parse JSON file(s) and resolve merge conflicts if possible. + + options: + -h, --help print this help and exit + -v, --version print version number and exit + -i, --in-place write resolved conflicts back to FILE (default to stdout) + -p, --prefer {ours|theirs} + prefer *our* or *their* version of the file, respectively + (default: 'ours') + + Exits 0 on success, 1 on error. + ), + ], +] +` + +exports[`test/cli.js > TAP > -v arg > exit code 1`] = ` +0 +` + +exports[`test/cli.js > TAP > -v arg > stderr 1`] = ` +Array [] +` + +exports[`test/cli.js > TAP > -v arg > stdout 1`] = ` +Array [ + Array [ + "{VERSION}", + ], +] +` + +exports[`test/cli.js > TAP > conflicting file (ours) > exit code 1`] = ` +1 +` + +exports[`test/cli.js > TAP > conflicting file (ours) > stderr 1`] = ` +Array [ + Array [ + "Error: unrecognized option --prefer", + ], +] +` + +exports[`test/cli.js > TAP > conflicting file (ours) > stdout 1`] = ` +Array [] +` + +exports[`test/cli.js > TAP > conflicting file (theirs, arg last) > exit code 1`] = ` +1 +` + +exports[`test/cli.js > TAP > conflicting file (theirs, arg last) > stderr 1`] = ` +Array [ + Array [ + "Error: unrecognized option --prefer", + ], +] +` + +exports[`test/cli.js > TAP > conflicting file (theirs, arg last) > stdout 1`] = ` +Array [] +` + +exports[`test/cli.js > TAP > conflicting file (theirs, arg separate) > exit code 1`] = ` +1 +` + +exports[`test/cli.js > TAP > conflicting file (theirs, arg separate) > stderr 1`] = ` +Array [ + Array [ + "Error: unrecognized option --prefer", + ], +] +` + +exports[`test/cli.js > TAP > conflicting file (theirs, arg separate) > stdout 1`] = ` +Array [] +` + +exports[`test/cli.js > TAP > conflicting file (theirs) > exit code 1`] = ` +1 +` + +exports[`test/cli.js > TAP > conflicting file (theirs) > stderr 1`] = ` +Array [ + Array [ + "Error: unrecognized option --prefer", + ], +] +` + +exports[`test/cli.js > TAP > conflicting file (theirs) > stdout 1`] = ` +Array [] +` + +exports[`test/cli.js > TAP > conflicting file > exit code 1`] = ` +0 +` + +exports[`test/cli.js > TAP > conflicting file > stderr 1`] = ` +Array [] +` + +exports[`test/cli.js > TAP > conflicting file > stdout 1`] = ` +Array [ + Array [ + Object { + "a": Object { + "b": Object { + "c": Object { + "x": "bbbb", + }, + }, + }, + "array": Array [ + 100, + Object { + "foo": "baz", + }, + 1, + 3, + Object { + "foo": "bar", + }, + 1, + ], + [Symbol.for(indent)]: "", + [Symbol.for(newline)]: "", + }, + ], +] +` + +exports[`test/cli.js > TAP > in-place updates > exit code 1`] = ` +1 +` + +exports[`test/cli.js > TAP > in-place updates > file broken.json 1`] = ` +{ + "array": [ +<<<<<<< HEAD + 100, + { + "foo": "baz", + }, +||||||| merged common ancestors + 1, +======= + 111, + 1, + 2, + 3, + { + "foo": "bar" + }, +>>>>>>> a + 1 + ], + "a": { + "b": { +<<<<<<< HEAD + "c": { + "x": "bbbb" + } +||||||| merged common ancestors + "c": { + "x": "aaaa" + } +======= + "c": "xxxx" +>>>>>>> a + } + } +} + +` + +exports[`test/cli.js > TAP > in-place updates > file conflicted.json 1`] = ` +{ + "array": [ +<<<<<<< HEAD + 100, + { + "foo": "baz" + }, +||||||| merged common ancestors + 1, +======= + 111, + 1, + 2, + 3, + { + "foo": "bar" + }, +>>>>>>> a + 1 + ], + "a": { + "b": { +<<<<<<< HEAD + "c": { + "x": "bbbb" + } +||||||| merged common ancestors + "c": { + "x": "aaaa" + } +======= + "c": "xxxx" +>>>>>>> a + } + } +} + +` + +exports[`test/cli.js > TAP > in-place updates > file proto.json 1`] = ` +{ + "constructor": "constructor", + "toString": "toString", + "__proto__": "__proto__", + "x": { +<<<<<<< + "__proto__": { + "foo": "__proto__.foo" + } +======= +>>>>>>> + } +} + +` + +exports[`test/cli.js > TAP > in-place updates > stderr 1`] = ` +Array [ + Array [ + "Error: unrecognized option --in-place", + ], +] +` + +exports[`test/cli.js > TAP > in-place updates > stdout 1`] = ` +Array [] +` + +exports[`test/cli.js > TAP > multiple files > exit code 1`] = ` +0 +` + +exports[`test/cli.js > TAP > multiple files > stderr 1`] = ` +Array [] +` + +exports[`test/cli.js > TAP > multiple files > stdout 1`] = ` +Array [ + Array [ + Object { + "a": Object { + "b": Object { + "c": Object { + "x": "bbbb", + }, + }, + }, + "array": Array [ + 100, + Object { + "foo": "baz", + }, + 1, + 3, + Object { + "foo": "bar", + }, + 1, + ], + [Symbol.for(indent)]: "", + [Symbol.for(newline)]: "", + }, + ], + Array [ + Object { + "a": Object { + "b": Object { + "c": Object { + "x": "bbbb", + }, + }, + }, + "array": Array [ + 100, + Object { + "foo": "baz", + }, + 1, + 3, + Object { + "foo": "bar", + }, + 1, + ], + [Symbol.for(indent)]: "", + [Symbol.for(newline)]: "", + }, + ], +] +` + +exports[`test/cli.js > TAP > no args > exit code 1`] = ` +1 +` + +exports[`test/cli.js > TAP > no args > stderr 1`] = ` +Array [ + Array [ + "Error: No files specified!", + ], +] +` + +exports[`test/cli.js > TAP > no args > stdout 1`] = ` +Array [] +` + +exports[`test/cli.js > TAP > nonexistent file > exit code 1`] = ` +1 +` + +exports[`test/cli.js > TAP > nonexistent file > stderr 1`] = ` +Array [ + Array [ + "Error: ENOENT: no such file or directory, open 'nonexistent_file.json'", + ], +] +` + +exports[`test/cli.js > TAP > nonexistent file > stdout 1`] = ` +Array [] +` diff --git a/test/cli.js b/test/cli.js new file mode 100644 index 0000000..f571da9 --- /dev/null +++ b/test/cli.js @@ -0,0 +1,62 @@ +const cli = require('../lib/cli') +const { version } = require('../package.json') +const t = require('tap') +const { readFileSync } = require('fs') + +const path = require('path') +const conflictedPath = path.join(__dirname, '/fixtures/conflicted.json'); +const brokenPath = path.join(__dirname, '/fixtures/broken.json'); +const protoPath = path.join(__dirname, '/fixtures/prototype.json'); + +t.cleanSnapshot = s => s.replace(version, '{VERSION}'); + +t.beforeEach(t => { + t.outs = t.capture(console, 'log'); + t.errs = t.capture(console, 'error'); +}); + +for (const desc_arg of [ + ['no args', []], + ['-h arg', ['-h']], + ['--help arg', ['--help']], + ['--help arg after other stuff', ['file1', 'file2', '--prefer', '--help']], + ['-v arg', ['-v']], + ['--version arg', ['--version']], + ['nonexistent file', ['nonexistent_file.json']], + ['conflicting file', [conflictedPath]], + ['conflicting file (ours)', ['--prefer=ours', conflictedPath]], + ['conflicting file (theirs)', ['--prefer=theirs', conflictedPath]], + ['conflicting file (theirs, arg last)', [conflictedPath, '--prefer=theirs']], + ['conflicting file (theirs, arg separate)', [conflictedPath, '--prefer', 'theirs']], + ['multiple files', [conflictedPath, conflictedPath]], +]) { + const [desc, argv] = desc_arg; + t.test(desc, async t => { + t.matchSnapshot(cli.main(argv), 'exit code'); + t.matchSnapshot(t.outs().map(({args}) => args), 'stdout'); + t.matchSnapshot(t.errs().map(({args}) => args), 'stderr'); + }) +} + +t.test('in-place updates', async t => { + + let files = { + 'broken.json': readFileSync(brokenPath), + 'conflicted.json': readFileSync(conflictedPath), + 'proto.json': readFileSync(protoPath), + }; + let filePaths = Object.keys(files).map(f => path.join(t.testdirName, f)); + t.testdir(files); + + t.matchSnapshot(cli.main(['--in-place', ...filePaths]), 'exit code'); + t.matchSnapshot(t.outs().map(({args}) => args), 'stdout'); + t.matchSnapshot(t.errs().map(({args}) => args), 'stderr'); + + for (let f of filePaths) { + let newContent = readFileSync(f).toString() + let oldContent = files[path.basename(f)]; + t.notSame(newContent, oldContent); + t.matchSnapshot(newContent, `file ${path.basename(f)}`); + } + +});