Add CLI command to clear sourcecred data directory (#1111)

Resolves #1067

Adds the CLI commands:
`sourcecred clear --all` -- removes the $SOURCECRED_DIRECTORY
`sourcecred clear --cache` -- removes the cache directory
`sourcecred clear --help` -- provides usage info
`sourcecred clear` -- prompts the user to be more specific

Test plan:
The unit tests ensure that the command is properly wired into the
 sourcecred CLI, including help text integration. However, just to be
safe, we can start by verifying that calling `sourcecred` without
arguments lists the `clear` command as a valid option, and that
calling `sourcecred help clear` prints help information. (Note: it's
necessary to run `yarn backend` before testing these changes)

The unit tests also ensure that the command removes the proper
directories, so there isn't really a need to manually test it,
although the reviewer may choose to do so to be safe.

Although out of scope for unit tests on this function, we can also do
integration tests, to make sure that running the clear command doesn't
leave the sourcecred directory in an invalid state from the perspective of the `load` command.

```js
$ yarn backend;
$ node bin/sourcecred.js load sourcecred/example-github;
$ node bin/sourcecred.js clear --cache;
$ node bin/sourcecred.js load sourcecred/example-github;
$ node bin/sourcecred.js clear --all;
$ node bin/sourcecred.js load sourcecred/example-github;
```
The expected behavior of the above command block is that the load command never fails or throws an error.

@decentralion and I discussed the scenario where `rimraf` errors.
We decided that testing this scenario wasn't necessary, because
`rimraf` doesn't error if a directory doesn't exist, and
rimraf's maintainer suggests [monkey-patching the fs module]
to get rimraf to error in testing scenarios.

Thanks @decentralion for reviewing and pair-programming this with me.

[monkey-patching the fs module]: https://github.com/isaacs/rimraf/issues/31#issuecomment-29534796
This commit is contained in:
Brian Litwin 2019-05-13 05:59:58 -04:00 committed by Dandelion Mané
parent bed476517c
commit 0f038305a2
9 changed files with 294 additions and 0 deletions

View File

@ -1,7 +1,9 @@
# Changelog # Changelog
## [Unreleased] ## [Unreleased]
- Add the `pagerank` command (#1114) - Add the `pagerank` command (#1114)
- Add the `clear` command (#1111)
- Add description tooltips for node and edge types in the weight configuration UI (#1081) - Add description tooltips for node and edge types in the weight configuration UI (#1081)
- Add the `export-graph` command (#1110) - Add the `export-graph` command (#1110)
- Enable loading private repositories (#1085) - Enable loading private repositories (#1085)
@ -11,6 +13,7 @@
<!-- Please add new entries to the _top_ of this section. --> <!-- Please add new entries to the _top_ of this section. -->
## [0.2.0] ## [0.2.0]
- Cache GitHub data, allowing for incremental and resumable loading (#622) - Cache GitHub data, allowing for incremental and resumable loading (#622)
- Hyperlink Git commits to GitHub (#887) - Hyperlink Git commits to GitHub (#887)
- Relicense from MIT to MIT + Apache-2 (#812) - Relicense from MIT to MIT + Apache-2 (#812)
@ -23,6 +26,7 @@
- Add `MentionsAuthor` edges to the graph (#808) - Add `MentionsAuthor` edges to the graph (#808)
## [0.1.0] ## [0.1.0]
- Organize weight config by plugin (#773) - Organize weight config by plugin (#773)
- Configure edge forward/backward weights separately (#749) - Configure edge forward/backward weights separately (#749)
- Combine "load graph" and "run pagerank" into one button (#759) - Combine "load graph" and "run pagerank" into one button (#759)

View File

@ -24,6 +24,7 @@
"react-router": "3.2.1", "react-router": "3.2.1",
"retry": "^0.12.0", "retry": "^0.12.0",
"svg-react-loader": "^0.4.6", "svg-react-loader": "^0.4.6",
"rimraf": "^2.6.3",
"tmp": "^0.0.33", "tmp": "^0.0.33",
"whatwg-fetch": "2.0.3" "whatwg-fetch": "2.0.3"
}, },

111
src/cli/clear.js Normal file
View File

@ -0,0 +1,111 @@
// @flow
// implementation of `sourcecred clear`
import path from "path";
import rimraf from "rimraf";
import dedent from "../util/dedent";
import {type Command} from "./command";
import * as Common from "./common";
function usage(print: (string) => void): void {
print(
dedent`\
usage: sourcecred clear --all
sourcecred clear --cache
sourcecred clear --help
Remove the SOURCECRED_DIRECTORY, i.e. the directory where data, caches,
registries, etc. owned by SourceCred are stored.
Arguments:
--all
remove entire SOURCECRED_DIRECTORY
--cache
remove only the SourcCred cache directory
--help
Show this help message and exit, as 'sourcecred help clear'.
Environment Variables:
SOURCECRED_DIRECTORY
Directory owned by SourceCred, in which data, caches,
registries, etc. are stored. Optional: defaults to a
directory 'sourcecred' under your OS's temporary directory;
namely:
${Common.defaultSourcecredDirectory()}
`.trimRight()
);
}
function die(std, message) {
std.err("fatal: " + message);
std.err("fatal: run 'sourcecred help clear' for help");
return 1;
}
export function makeClear(removeDir: (string) => Promise<void>): Command {
return async function clear(args, std) {
async function remove(dir) {
try {
await removeDir(dir);
return 0;
} catch (error) {
return die(std, `${error}`);
}
}
switch (args.length) {
case 0:
return die(std, "no arguments provided");
case 1:
switch (args[0]) {
case "--help":
usage(std.out);
return 0;
case "--all":
return remove(Common.sourcecredDirectory());
case "--cache":
return remove(path.join(Common.sourcecredDirectory(), "cache"));
default:
return die(std, `unrecognized argument: '${args[0]}'`);
}
default:
return die(
std,
`expected 1 argument but recieved: ${args.length} arguments`
);
}
};
}
export function removeDir(p: string): Promise<void> {
return new Promise((resolve, reject) =>
rimraf(p, (error) => {
if (error) {
reject(error);
} else {
resolve();
}
})
);
}
export const help: Command = async (args, std) => {
if (args.length === 0) {
usage(std.out);
return 0;
} else {
usage(std.err);
return 1;
}
};
export const clear = makeClear(removeDir);
export default clear;

134
src/cli/clear.test.js Normal file
View File

@ -0,0 +1,134 @@
// @flow
import path from "path";
import tmp from "tmp";
import fs from "fs";
import {makeClear, removeDir, help} from "./clear";
import {run} from "./testUtil";
import * as Common from "./common";
describe("cli/clear", () => {
describe("'help' command", () => {
it("prints usage when given no arguments", async () => {
expect(await run(help, [])).toEqual({
exitCode: 0,
stdout: expect.arrayContaining([
expect.stringMatching(/^usage: sourcecred clear/),
]),
stderr: [],
});
});
it("fails when given arguments", async () => {
expect(await run(help, ["foo/bar"])).toEqual({
exitCode: 1,
stdout: [],
stderr: expect.arrayContaining([
expect.stringMatching(/^usage: sourcecred clear/),
]),
});
});
});
describe("'makeClear' command", () => {
it("prints usage with '--help'", async () => {
const clear = makeClear(jest.fn());
expect(await run(clear, ["--help"])).toEqual({
exitCode: 0,
stdout: expect.arrayContaining([
expect.stringMatching(/^usage: sourcecred clear/),
]),
stderr: [],
});
});
it("fails when no arguments specified", async () => {
const clear = makeClear(jest.fn());
expect(await run(clear, [])).toEqual({
exitCode: 1,
stdout: [],
stderr: [
"fatal: no arguments provided",
"fatal: run 'sourcecred help clear' for help",
],
});
});
it("fails when an invalid argument is specified", async () => {
const clear = makeClear(jest.fn());
expect(await run(clear, ["invalid"])).toEqual({
exitCode: 1,
stdout: [],
stderr: [
"fatal: unrecognized argument: 'invalid'",
"fatal: run 'sourcecred help clear' for help",
],
});
});
it("fails when more than one argument specified", async () => {
const clear = makeClear(jest.fn());
expect(await run(clear, ["1", "2"])).toEqual({
exitCode: 1,
stdout: [],
stderr: [
"fatal: expected 1 argument but recieved: 2 arguments",
"fatal: run 'sourcecred help clear' for help",
],
});
});
it("passes correct param to removeDir with `--all`", async () => {
const rmDir = jest.fn();
const clear = makeClear(rmDir);
await run(clear, ["--all"]);
expect(rmDir).toHaveBeenCalledWith(Common.sourcecredDirectory());
});
it("passes correct param to removeDir with `--cache`", async () => {
const rmDir = jest.fn();
const clear = makeClear(rmDir);
await run(clear, ["--cache"]);
const cacheDir = path.join(Common.sourcecredDirectory(), "cache");
expect(rmDir).toHaveBeenCalledWith(cacheDir);
});
function throwError() {
return Promise.reject(new Error("test error"));
}
it("--all returns error if removeDir errors", async () => {
const clear = makeClear(throwError);
expect(await run(clear, ["--all"])).toEqual({
exitCode: 1,
stdout: [],
stderr: [
"fatal: Error: test error",
"fatal: run 'sourcecred help clear' for help",
],
});
});
it("--cache returns error if removeDir errors", async () => {
const clear = makeClear(throwError);
expect(await run(clear, ["--cache"])).toEqual({
exitCode: 1,
stdout: [],
stderr: [
"fatal: Error: test error",
"fatal: run 'sourcecred help clear' for help",
],
});
});
});
describe("rimraf", () => {
it("removes the correct directory", async () => {
const dir = tmp.dirSync();
expect(fs.existsSync(dir.name)).toBe(true);
await removeDir(dir.name);
expect(fs.existsSync(dir.name)).toBe(false);
});
});
});

View File

@ -8,6 +8,7 @@ import {help as loadHelp} from "./load";
import {help as analyzeHelp} from "./analyze"; import {help as analyzeHelp} from "./analyze";
import {help as pagerankHelp} from "./pagerank"; import {help as pagerankHelp} from "./pagerank";
import {help as exportGraphHelp} from "./exportGraph"; import {help as exportGraphHelp} from "./exportGraph";
import {help as clearHelp} from "./clear";
const help: Command = async (args, std) => { const help: Command = async (args, std) => {
if (args.length === 0) { if (args.length === 0) {
@ -20,6 +21,7 @@ const help: Command = async (args, std) => {
load: loadHelp, load: loadHelp,
analyze: analyzeHelp, analyze: analyzeHelp,
pagerank: pagerankHelp, pagerank: pagerankHelp,
clear: clearHelp,
"export-graph": exportGraphHelp, "export-graph": exportGraphHelp,
}; };
if (subHelps[command] !== undefined) { if (subHelps[command] !== undefined) {
@ -43,6 +45,7 @@ function usage(print: (string) => void): void {
analyze analyze cred for a loaded repository analyze analyze cred for a loaded repository
export-graph print a raw SourceCred graph export-graph print a raw SourceCred graph
pagerank recompute cred scores pagerank recompute cred scores
clear clear SoucrceCred data
help show this help message help show this help message
Use 'sourcecred help COMMAND' for help about an individual command. Use 'sourcecred help COMMAND' for help about an individual command.

View File

@ -55,6 +55,16 @@ describe("cli/help", () => {
}); });
}); });
it("prints help about 'sourcecred clear'", async () => {
expect(await run(help, ["clear"])).toEqual({
exitCode: 0,
stdout: expect.arrayContaining([
expect.stringMatching(/^usage: sourcecred clear/),
]),
stderr: [],
});
});
it("fails when given an unknown command", async () => { it("fails when given an unknown command", async () => {
expect(await run(help, ["wat"])).toEqual({ expect(await run(help, ["wat"])).toEqual({
exitCode: 1, exitCode: 1,

View File

@ -10,6 +10,7 @@ import load from "./load";
import analyze from "./analyze"; import analyze from "./analyze";
import exportGraph from "./exportGraph"; import exportGraph from "./exportGraph";
import pagerank from "./pagerank"; import pagerank from "./pagerank";
import clear from "./clear";
const sourcecred: Command = async (args, std) => { const sourcecred: Command = async (args, std) => {
if (args.length === 0) { if (args.length === 0) {
@ -27,6 +28,8 @@ const sourcecred: Command = async (args, std) => {
return load(args.slice(1), std); return load(args.slice(1), std);
case "analyze": case "analyze":
return analyze(args.slice(1), std); return analyze(args.slice(1), std);
case "clear":
return clear(args.slice(1), std);
case "export-graph": case "export-graph":
return exportGraph(args.slice(1), std); return exportGraph(args.slice(1), std);
case "pagerank": case "pagerank":

View File

@ -16,6 +16,7 @@ jest.mock("./load", () => mockCommand("load"));
jest.mock("./analyze", () => mockCommand("analyze")); jest.mock("./analyze", () => mockCommand("analyze"));
jest.mock("./exportGraph", () => mockCommand("export-graph")); jest.mock("./exportGraph", () => mockCommand("export-graph"));
jest.mock("./pagerank", () => mockCommand("pagerank")); jest.mock("./pagerank", () => mockCommand("pagerank"));
jest.mock("./clear", () => mockCommand("clear"));
describe("cli/sourcecred", () => { describe("cli/sourcecred", () => {
it("fails with usage when invoked with no arguments", async () => { it("fails with usage when invoked with no arguments", async () => {
@ -84,6 +85,14 @@ describe("cli/sourcecred", () => {
}); });
}); });
it("responds to 'clear --all'", async () => {
expect(await run(sourcecred, ["clear", "--all"])).toEqual({
exitCode: 1,
stdout: ['out(clear): ["--all"]'],
stderr: ["err(clear)"],
});
});
it("fails given an unknown command", async () => { it("fails given an unknown command", async () => {
expect(await run(sourcecred, ["wat"])).toEqual({ expect(await run(sourcecred, ["wat"])).toEqual({
exitCode: 1, exitCode: 1,

View File

@ -3692,6 +3692,18 @@ glob@^7.0.3, glob@^7.0.5, glob@^7.1.1, glob@^7.1.2:
once "^1.3.0" once "^1.3.0"
path-is-absolute "^1.0.0" path-is-absolute "^1.0.0"
glob@^7.1.3:
version "7.1.3"
resolved "https://registry.yarnpkg.com/glob/-/glob-7.1.3.tgz#3960832d3f1574108342dafd3a67b332c0969df1"
integrity sha512-vcfuiIxogLV4DlGBHIUOwI0IbrJ8HWPc4MU7HzviGeNho/UJDfi6B5p3sHeWIQ0KGIU0Jpxi5ZHxemQfLkkAwQ==
dependencies:
fs.realpath "^1.0.0"
inflight "^1.0.4"
inherits "2"
minimatch "^3.0.4"
once "^1.3.0"
path-is-absolute "^1.0.0"
global-modules@1.0.0, global-modules@^1.0.0: global-modules@1.0.0, global-modules@^1.0.0:
version "1.0.0" version "1.0.0"
resolved "https://registry.yarnpkg.com/global-modules/-/global-modules-1.0.0.tgz#6d770f0eb523ac78164d72b5e71a8877265cc3ea" resolved "https://registry.yarnpkg.com/global-modules/-/global-modules-1.0.0.tgz#6d770f0eb523ac78164d72b5e71a8877265cc3ea"
@ -7483,6 +7495,13 @@ rimraf@2, rimraf@^2.2.8, rimraf@^2.5.1, rimraf@^2.5.4, rimraf@^2.6.1, rimraf@^2.
dependencies: dependencies:
glob "^7.0.5" glob "^7.0.5"
rimraf@^2.6.3:
version "2.6.3"
resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-2.6.3.tgz#b2d104fe0d8fb27cf9e0a1cda8262dd3833c6cab"
integrity sha512-mwqeW5XsA2qAejG46gYdENaxXjx9onRNCfn7L0duuP4hCuTIi/QO7PDK07KJfp1d+izWPrzEJDcSqBa0OZQriA==
dependencies:
glob "^7.1.3"
ripemd160@^2.0.0, ripemd160@^2.0.1: ripemd160@^2.0.0, ripemd160@^2.0.1:
version "2.0.1" version "2.0.1"
resolved "https://registry.yarnpkg.com/ripemd160/-/ripemd160-2.0.1.tgz#0f4584295c53a3628af7e6d79aca21ce57d1c6e7" resolved "https://registry.yarnpkg.com/ripemd160/-/ripemd160-2.0.1.tgz#0f4584295c53a3628af7e6d79aca21ce57d1c6e7"