ember.nvp 0.5.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.release-plan.json +3 -7
- package/CHANGELOG.md +11 -0
- package/README.md +29 -0
- package/package.json +2 -1
- package/src/cli/args.js +7 -1
- package/src/cli/index.js +70 -15
- package/src/cli/questions/write.js +169 -0
- package/src/index.js +1 -0
- package/src/utils/stage.js +317 -0
- package/test/ember.nvp/stage.test.ts +188 -0
package/.release-plan.json
CHANGED
|
@@ -2,17 +2,13 @@
|
|
|
2
2
|
"solution": {
|
|
3
3
|
"ember.nvp": {
|
|
4
4
|
"impact": "minor",
|
|
5
|
-
"oldVersion": "0.
|
|
6
|
-
"newVersion": "0.
|
|
5
|
+
"oldVersion": "0.5.0",
|
|
6
|
+
"newVersion": "0.6.0",
|
|
7
7
|
"tagName": "latest",
|
|
8
8
|
"constraints": [
|
|
9
9
|
{
|
|
10
10
|
"impact": "minor",
|
|
11
11
|
"reason": "Appears in changelog section :rocket: Enhancement"
|
|
12
|
-
},
|
|
13
|
-
{
|
|
14
|
-
"impact": "patch",
|
|
15
|
-
"reason": "Appears in changelog section :house: Internal"
|
|
16
12
|
}
|
|
17
13
|
],
|
|
18
14
|
"pkgJSONPath": "./package.json"
|
|
@@ -21,5 +17,5 @@
|
|
|
21
17
|
"oldVersion": "1.0.3"
|
|
22
18
|
}
|
|
23
19
|
},
|
|
24
|
-
"description": "## Release (2026-
|
|
20
|
+
"description": "## Release (2026-07-04)\n\n* ember.nvp 0.6.0 (minor)\n\n#### :rocket: Enhancement\n* `ember.nvp`\n * [#56](https://github.com/NullVoxPopuli/ember.nvp/pull/56) Stage all generation in a copy-on-write overlay; confirm before writing ([@NullVoxPopuli-ai-agent](https://github.com/NullVoxPopuli-ai-agent))\n\n#### Committers: 1\n- @NullVoxPopuli's reduced-access machine account for AI usage ([@NullVoxPopuli-ai-agent](https://github.com/NullVoxPopuli-ai-agent))\n"
|
|
25
21
|
}
|
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,16 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## Release (2026-07-04)
|
|
4
|
+
|
|
5
|
+
* ember.nvp 0.6.0 (minor)
|
|
6
|
+
|
|
7
|
+
#### :rocket: Enhancement
|
|
8
|
+
* `ember.nvp`
|
|
9
|
+
* [#56](https://github.com/NullVoxPopuli/ember.nvp/pull/56) Stage all generation in a copy-on-write overlay; confirm before writing ([@NullVoxPopuli-ai-agent](https://github.com/NullVoxPopuli-ai-agent))
|
|
10
|
+
|
|
11
|
+
#### Committers: 1
|
|
12
|
+
- @NullVoxPopuli's reduced-access machine account for AI usage ([@NullVoxPopuli-ai-agent](https://github.com/NullVoxPopuli-ai-agent))
|
|
13
|
+
|
|
3
14
|
## Release (2026-06-29)
|
|
4
15
|
|
|
5
16
|
* ember.nvp 0.5.0 (minor)
|
package/README.md
CHANGED
|
@@ -30,6 +30,18 @@ pnpm dlx NullVoxPopuli/ember.nvp
|
|
|
30
30
|
npx NullVoxPopuli/ember.nvp
|
|
31
31
|
```
|
|
32
32
|
|
|
33
|
+
### Updating an existing project
|
|
34
|
+
|
|
35
|
+
You can run `ember.nvp` on top of an existing project to add layers to it. Generation never writes directly to your project -- everything runs in a staging directory first, and before finishing you choose to:
|
|
36
|
+
|
|
37
|
+
- **write the files** -- apply all of the staged changes
|
|
38
|
+
- **review the diff** -- step through each changed file's diff, accepting or rejecting it individually (or accept/reject everything remaining)
|
|
39
|
+
- **cancel** -- discard everything; your project is untouched
|
|
40
|
+
|
|
41
|
+
Only files that actually changed are written. `node_modules`, `.git`, and everything else in your project are left alone, and the accepted changes land as uncommitted edits for you to review with your own git tooling.
|
|
42
|
+
|
|
43
|
+
New projects skip the confirmation and are written as soon as generation succeeds. For scripting, `--write yes` / `--write no` answers the confirmation up front.
|
|
44
|
+
|
|
33
45
|
### Wrapping
|
|
34
46
|
|
|
35
47
|
The provided CLI is only a wrapper around our exported `generateProject` function.
|
|
@@ -55,6 +67,23 @@ await generateProject(new Project(
|
|
|
55
67
|
|
|
56
68
|
All parts of the generator are idempotent, so running generators on existing projects _can_ no-op.
|
|
57
69
|
|
|
70
|
+
To get the same don't-write-until-confirmed behavior as the CLI, wrap generation in a `Stage`. A stage is a real directory in the OS temp dir, seeded with a copy of the target directory's current contents (sans `node_modules`/`.git`) -- so layers run against the project's existing state with plain `node:fs`, `ember-apply`, and subprocesses, and authoring a layer is exactly the same with or without one.
|
|
71
|
+
|
|
72
|
+
```js
|
|
73
|
+
import { generateProject, Project, Stage } from "ember.nvp";
|
|
74
|
+
|
|
75
|
+
const stage = await Stage.create(directoryToGenerateIn);
|
|
76
|
+
|
|
77
|
+
await generateProject(new Project(stage.directory, { name, path, type, layers, packageManager }));
|
|
78
|
+
|
|
79
|
+
const changes = await stage.changes(); // [{ path, status: 'added' | 'modified' | 'deleted' }]
|
|
80
|
+
console.log(await stage.diff(changes)); // unified diff against the target directory
|
|
81
|
+
|
|
82
|
+
await stage.commit(); // write the changes to the target directory
|
|
83
|
+
// or: await stage.commit(someOfTheChanges) -- write only an accepted subset
|
|
84
|
+
// or: await stage.discard() -- throw everything away, touching nothing
|
|
85
|
+
```
|
|
86
|
+
|
|
58
87
|
## Reqs
|
|
59
88
|
|
|
60
89
|
- node 24+
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ember.nvp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"description": "ember project generator, alternative to the official blueprints, but more modern -- hopefully one day to upstream back in to ember-cli / official blueprints",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
"@clack/prompts": "^1.6.0",
|
|
23
23
|
"babel-remove-types": "^2.0.0",
|
|
24
24
|
"content-tag": "^4.2.0",
|
|
25
|
+
"diff": "^9.0.0",
|
|
25
26
|
"ember-apply": "^2.17.0",
|
|
26
27
|
"execa": "^9.6.1",
|
|
27
28
|
"latest-version": "^9.0.0",
|
package/src/cli/args.js
CHANGED
|
@@ -35,10 +35,15 @@ const { values } = parseArgs({
|
|
|
35
35
|
type: "string",
|
|
36
36
|
choices: ["replace", "update"],
|
|
37
37
|
},
|
|
38
|
+
|
|
39
|
+
write: {
|
|
40
|
+
type: "string",
|
|
41
|
+
choices: ["yes", "no"],
|
|
42
|
+
},
|
|
38
43
|
},
|
|
39
44
|
});
|
|
40
45
|
|
|
41
|
-
const { replaceOrUpdate, name, type, layers = [], packageManager, path, confirm } = values;
|
|
46
|
+
const { replaceOrUpdate, name, type, layers = [], packageManager, path, confirm, write } = values;
|
|
42
47
|
|
|
43
48
|
export const answers = {
|
|
44
49
|
name,
|
|
@@ -48,6 +53,7 @@ export const answers = {
|
|
|
48
53
|
path,
|
|
49
54
|
confirm,
|
|
50
55
|
replaceOrUpdate,
|
|
56
|
+
write,
|
|
51
57
|
};
|
|
52
58
|
|
|
53
59
|
/**
|
package/src/cli/index.js
CHANGED
|
@@ -8,8 +8,11 @@ import { askProjectType } from "./questions/project-type.js";
|
|
|
8
8
|
import { askPackageManager } from "./questions/package-manager.js";
|
|
9
9
|
import { askPath } from "./questions/path.js";
|
|
10
10
|
import { askIfOK } from "./questions/ok.js";
|
|
11
|
+
import { askToWrite } from "./questions/write.js";
|
|
11
12
|
import { styleText } from "node:util";
|
|
13
|
+
import { rm } from "node:fs/promises";
|
|
12
14
|
import { Project } from "#utils/project.js";
|
|
15
|
+
import { Stage } from "#utils/stage.js";
|
|
13
16
|
import { askReplaceOrUpdate } from "./questions/replace-or-update.js";
|
|
14
17
|
|
|
15
18
|
/**
|
|
@@ -31,7 +34,26 @@ async function main() {
|
|
|
31
34
|
const selectedLayers = await askLayers();
|
|
32
35
|
const packageManager = await askPackageManager();
|
|
33
36
|
|
|
34
|
-
|
|
37
|
+
await askIfOK();
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Everything is generated in a stage: a real directory (in the OS temp
|
|
41
|
+
* dir) acting as a writable overlay of the target directory.
|
|
42
|
+
*
|
|
43
|
+
* When updating an existing project, the stage is seeded with a full
|
|
44
|
+
* copy of it (sans node_modules/.git), so the bases and layers run
|
|
45
|
+
* against the project's current state -- isSetup checks, package.json
|
|
46
|
+
* reads, file existence checks, etc. all see the existing files, and
|
|
47
|
+
* the codemods modify them in the stage exactly as if they were
|
|
48
|
+
* operating in place. "replace" starts from an empty stage instead.
|
|
49
|
+
*
|
|
50
|
+
* The target itself is not touched until stage.commit() below, which
|
|
51
|
+
* applies the difference between the stage and the target (or the
|
|
52
|
+
* subset of it the user accepts during review).
|
|
53
|
+
*/
|
|
54
|
+
const stage = await Stage.create(projectPath, { seed: replaceOrUpdate !== "replace" });
|
|
55
|
+
|
|
56
|
+
let project = new Project(stage.directory, {
|
|
35
57
|
name: projectName,
|
|
36
58
|
type: projectType,
|
|
37
59
|
path: projectPath,
|
|
@@ -39,28 +61,19 @@ async function main() {
|
|
|
39
61
|
packageManager,
|
|
40
62
|
});
|
|
41
63
|
|
|
42
|
-
await askIfOK();
|
|
43
|
-
|
|
44
64
|
const s = p.spinner();
|
|
45
65
|
s.start("Creating your Ember app...");
|
|
46
66
|
|
|
47
67
|
try {
|
|
48
|
-
// Generate the project
|
|
49
|
-
await generateProject(project
|
|
50
|
-
|
|
51
|
-
s.stop("Project created!");
|
|
68
|
+
// Generate the project (into the stage)
|
|
69
|
+
await generateProject(project);
|
|
52
70
|
|
|
53
|
-
|
|
54
|
-
`cd ${projectName}\n` +
|
|
55
|
-
`${packageManager} install\n` +
|
|
56
|
-
`${packageManager} ${packageManager === "npm" ? "run " : ""}start`,
|
|
57
|
-
"Next steps",
|
|
58
|
-
);
|
|
59
|
-
|
|
60
|
-
p.outro(styleText("green", "✓") + " Project ready! " + styleText("dim", "Happy coding!"));
|
|
71
|
+
s.stop("Project generated");
|
|
61
72
|
} catch (err) {
|
|
62
73
|
s.stop("Failed to create project");
|
|
63
74
|
|
|
75
|
+
await stage.discard();
|
|
76
|
+
|
|
64
77
|
if (err instanceof Error) {
|
|
65
78
|
p.cancel(`Error: ${err.message}`);
|
|
66
79
|
if (err.stack) {
|
|
@@ -73,6 +86,48 @@ async function main() {
|
|
|
73
86
|
p.cancel(`Error: ${String(err)}`);
|
|
74
87
|
process.exit(1);
|
|
75
88
|
}
|
|
89
|
+
|
|
90
|
+
const changes = await stage.changes();
|
|
91
|
+
|
|
92
|
+
if (changes.length === 0 && replaceOrUpdate !== "replace") {
|
|
93
|
+
await stage.discard();
|
|
94
|
+
|
|
95
|
+
p.outro(styleText("green", "✓") + " Already up to date -- nothing to write.");
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* New projects (and "replace", which was already confirmed) are written
|
|
101
|
+
* without asking. Updates to an existing project must be confirmed:
|
|
102
|
+
* write all / review each change (accept/reject) / cancel.
|
|
103
|
+
*/
|
|
104
|
+
const isUpdate = !stage.isNew && replaceOrUpdate !== "replace";
|
|
105
|
+
|
|
106
|
+
const toApply = isUpdate ? await askToWrite(stage, changes) : changes;
|
|
107
|
+
|
|
108
|
+
if (!toApply || toApply.length === 0) {
|
|
109
|
+
await stage.discard();
|
|
110
|
+
|
|
111
|
+
p.cancel("Cancelled -- no files were written");
|
|
112
|
+
return process.exit(0);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (replaceOrUpdate === "replace") {
|
|
116
|
+
await rm(projectPath, { recursive: true, force: true });
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
await stage.commit(toApply);
|
|
120
|
+
|
|
121
|
+
p.log.success(`Applied ${toApply.length} change${toApply.length === 1 ? "" : "s"}`);
|
|
122
|
+
|
|
123
|
+
p.note(
|
|
124
|
+
`cd ${projectName}\n` +
|
|
125
|
+
`${packageManager} install\n` +
|
|
126
|
+
`${packageManager} ${packageManager === "npm" ? "run " : ""}start`,
|
|
127
|
+
"Next steps",
|
|
128
|
+
);
|
|
129
|
+
|
|
130
|
+
p.outro(styleText("green", "✓") + " Project ready! " + styleText("dim", "Happy coding!"));
|
|
76
131
|
}
|
|
77
132
|
|
|
78
133
|
main().catch((err) => {
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import * as p from "@clack/prompts";
|
|
2
|
+
import { styleText } from "node:util";
|
|
3
|
+
|
|
4
|
+
import { answers, printArgInUse } from "#args";
|
|
5
|
+
|
|
6
|
+
const SUPPORTED = new Set(["yes", "no"]);
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
*
|
|
10
|
+
* @param {string | undefined} selected
|
|
11
|
+
* @returns {selected is 'yes' | 'no'}
|
|
12
|
+
*/
|
|
13
|
+
function isValid(selected) {
|
|
14
|
+
if (!selected) return false;
|
|
15
|
+
|
|
16
|
+
return SUPPORTED.has(selected);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Nothing has been written to the target directory yet -- everything so
|
|
21
|
+
* far only exists in the stage. Ask whether to write the staged changes,
|
|
22
|
+
* with the option to review the diff change-by-change, accepting or
|
|
23
|
+
* rejecting each one.
|
|
24
|
+
*
|
|
25
|
+
* @param {import('#utils/stage.js').Stage} stage
|
|
26
|
+
* @param {import('#utils/stage.js').Change[]} changes
|
|
27
|
+
* @returns {Promise<import('#utils/stage.js').Change[] | null>}
|
|
28
|
+
* the changes to write, or null to cancel without writing anything
|
|
29
|
+
*/
|
|
30
|
+
export async function askToWrite(stage, changes) {
|
|
31
|
+
if (isValid(answers.write)) {
|
|
32
|
+
printArgInUse("write", answers.write);
|
|
33
|
+
|
|
34
|
+
return answers.write === "yes" ? changes : null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
p.note(summarize(changes), `${changes.length} staged change${changes.length === 1 ? "" : "s"}`);
|
|
38
|
+
|
|
39
|
+
const answer = await p.select({
|
|
40
|
+
message: `Write these changes to ${stage.targetDirectory}?`,
|
|
41
|
+
options: [
|
|
42
|
+
{ value: "yes", label: "write the files", hint: "apply all staged changes" },
|
|
43
|
+
{
|
|
44
|
+
value: "review",
|
|
45
|
+
label: "review the diff",
|
|
46
|
+
hint: "step through each change, accepting or rejecting it",
|
|
47
|
+
},
|
|
48
|
+
{ value: "no", label: "cancel", hint: "discard the staged changes, touching nothing" },
|
|
49
|
+
],
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
if (p.isCancel(answer) || answer === "no") {
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (answer === "yes") {
|
|
57
|
+
return changes;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return reviewEachChange(stage, changes);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Show each change's diff and ask what to do with it. "accept remaining"
|
|
65
|
+
* and "reject remaining" finish the review early, so they require an
|
|
66
|
+
* extra confirmation; declining the confirmation returns to the change
|
|
67
|
+
* being reviewed.
|
|
68
|
+
*
|
|
69
|
+
* @param {import('#utils/stage.js').Stage} stage
|
|
70
|
+
* @param {import('#utils/stage.js').Change[]} changes
|
|
71
|
+
* @returns {Promise<import('#utils/stage.js').Change[] | null>}
|
|
72
|
+
* the accepted changes, or null to cancel the whole run
|
|
73
|
+
*/
|
|
74
|
+
async function reviewEachChange(stage, changes) {
|
|
75
|
+
/** @type {import('#utils/stage.js').Change[]} */
|
|
76
|
+
const accepted = [];
|
|
77
|
+
let index = 0;
|
|
78
|
+
|
|
79
|
+
while (index < changes.length) {
|
|
80
|
+
const change = changes[index];
|
|
81
|
+
|
|
82
|
+
if (!change) break;
|
|
83
|
+
|
|
84
|
+
const remaining = changes.length - index;
|
|
85
|
+
|
|
86
|
+
// Bypass clack for the diff itself: diffs are long, and clack's
|
|
87
|
+
// log decorations wrap every line.
|
|
88
|
+
console.log("\n" + (await stage.diff([change])));
|
|
89
|
+
|
|
90
|
+
const answer = await p.select({
|
|
91
|
+
message: `${change.path} (${index + 1} of ${changes.length})`,
|
|
92
|
+
options: [
|
|
93
|
+
{ value: "accept", label: "accept", hint: "write this change" },
|
|
94
|
+
{ value: "reject", label: "reject", hint: "skip this change" },
|
|
95
|
+
...(remaining > 1
|
|
96
|
+
? /** @type {const} */ ([
|
|
97
|
+
{
|
|
98
|
+
value: "accept-remaining",
|
|
99
|
+
label: "accept remaining",
|
|
100
|
+
hint: `accept this and the ${remaining - 1} unreviewed change${remaining - 1 === 1 ? "" : "s"} after it`,
|
|
101
|
+
},
|
|
102
|
+
{
|
|
103
|
+
value: "reject-remaining",
|
|
104
|
+
label: "reject remaining",
|
|
105
|
+
hint: `reject this and the ${remaining - 1} unreviewed change${remaining - 1 === 1 ? "" : "s"} after it`,
|
|
106
|
+
},
|
|
107
|
+
])
|
|
108
|
+
: []),
|
|
109
|
+
],
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
if (p.isCancel(answer)) {
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (answer === "accept") {
|
|
117
|
+
accepted.push(change);
|
|
118
|
+
index++;
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (answer === "reject") {
|
|
123
|
+
index++;
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const bulkAccept = answer === "accept-remaining";
|
|
128
|
+
const confirmed = await p.confirm({
|
|
129
|
+
message: `${bulkAccept ? "Accept" : "Reject"} all ${remaining} remaining change${remaining === 1 ? "" : "s"} without reviewing them?`,
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
if (p.isCancel(confirmed) || !confirmed) {
|
|
133
|
+
// return to the change being reviewed
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if (bulkAccept) {
|
|
138
|
+
accepted.push(...changes.slice(index));
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
break;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
p.log.info(
|
|
145
|
+
`Accepted ${accepted.length} of ${changes.length} change${changes.length === 1 ? "" : "s"}`,
|
|
146
|
+
);
|
|
147
|
+
|
|
148
|
+
return accepted;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* @param {import('#utils/stage.js').Change[]} changes
|
|
153
|
+
* @returns {string}
|
|
154
|
+
*/
|
|
155
|
+
function summarize(changes) {
|
|
156
|
+
const colors = /** @type {const} */ ({
|
|
157
|
+
added: "green",
|
|
158
|
+
modified: "yellow",
|
|
159
|
+
deleted: "red",
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
return changes
|
|
163
|
+
.map((change) => {
|
|
164
|
+
const letter = change.status[0]?.toUpperCase() ?? "?";
|
|
165
|
+
|
|
166
|
+
return `${styleText(colors[change.status], letter)} ${change.path}`;
|
|
167
|
+
})
|
|
168
|
+
.join("\n");
|
|
169
|
+
}
|
package/src/index.js
CHANGED
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { cp, mkdir, mkdtemp, readdir, readFile, rm } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { basename, dirname, join, relative, sep } from "node:path";
|
|
5
|
+
import { styleText } from "node:util";
|
|
6
|
+
import { structuredPatch } from "diff";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Directories that belong to the target project's environment rather than
|
|
10
|
+
* its generated contents. They are never seeded into the stage, never
|
|
11
|
+
* diffed, and never deleted on commit.
|
|
12
|
+
*
|
|
13
|
+
* (`.git` has one exception: see {@link Stage#commit})
|
|
14
|
+
*/
|
|
15
|
+
const ENVIRONMENT_DIRECTORIES = new Set(["node_modules", ".git"]);
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* @typedef {'added' | 'modified' | 'deleted'} ChangeStatus
|
|
19
|
+
* @typedef {{ path: string, status: ChangeStatus }} Change
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* A copy-on-write overlay for a project directory, in the spirit of
|
|
24
|
+
* Docker's layered file systems:
|
|
25
|
+
*
|
|
26
|
+
* - the target directory is the read-only lower layer
|
|
27
|
+
* - a real directory in the OS temp dir is the writable upper layer,
|
|
28
|
+
* seeded ("copied up") from the target
|
|
29
|
+
*
|
|
30
|
+
* All generation (bases, layers, consolidators) runs against the upper
|
|
31
|
+
* layer via `stage.directory`. Because the upper layer is a real
|
|
32
|
+
* directory, layers keep using `node:fs`, `ember-apply`, and even
|
|
33
|
+
* subprocesses (`git`, package managers) exactly as they would against
|
|
34
|
+
* the real project -- an in-process virtual fs can't offer that, since
|
|
35
|
+
* child processes can't see it.
|
|
36
|
+
*
|
|
37
|
+
* Nothing touches the target until {@link Stage#commit}; {@link Stage#discard}
|
|
38
|
+
* throws the upper layer away.
|
|
39
|
+
*/
|
|
40
|
+
export class Stage {
|
|
41
|
+
/**
|
|
42
|
+
* @param {string} targetDirectory the directory the project should end up in
|
|
43
|
+
* @param {{ seed?: boolean }} [options]
|
|
44
|
+
* seed: copy the target's current contents into the stage first
|
|
45
|
+
* (a "copy up"). Defaults to true. Pass false to stage a from-scratch
|
|
46
|
+
* generation (e.g. the "replace" flow).
|
|
47
|
+
* @returns {Promise<Stage>}
|
|
48
|
+
*/
|
|
49
|
+
static async create(targetDirectory, options = {}) {
|
|
50
|
+
const { seed = true } = options;
|
|
51
|
+
const directory = await mkdtemp(join(tmpdir(), "ember.nvp-stage-"));
|
|
52
|
+
const exists = existsSync(targetDirectory);
|
|
53
|
+
|
|
54
|
+
if (exists && seed) {
|
|
55
|
+
await cp(targetDirectory, directory, {
|
|
56
|
+
recursive: true,
|
|
57
|
+
force: true,
|
|
58
|
+
filter: (source) => !ENVIRONMENT_DIRECTORIES.has(basename(source)),
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return new Stage(targetDirectory, directory, { isNew: !exists });
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
#target;
|
|
66
|
+
#directory;
|
|
67
|
+
#isNew;
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Use {@link Stage.create} instead.
|
|
71
|
+
*
|
|
72
|
+
* @param {string} target
|
|
73
|
+
* @param {string} directory
|
|
74
|
+
* @param {{ isNew: boolean }} info
|
|
75
|
+
*/
|
|
76
|
+
constructor(target, directory, info) {
|
|
77
|
+
this.#target = target;
|
|
78
|
+
this.#directory = directory;
|
|
79
|
+
this.#isNew = info.isNew;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* The writable upper layer. Generate in here,
|
|
84
|
+
* e.g. `new Project(stage.directory, desires)`.
|
|
85
|
+
*
|
|
86
|
+
* @type {string}
|
|
87
|
+
*/
|
|
88
|
+
get directory() {
|
|
89
|
+
return this.#directory;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* The directory a commit writes to.
|
|
94
|
+
*
|
|
95
|
+
* @type {string}
|
|
96
|
+
*/
|
|
97
|
+
get targetDirectory() {
|
|
98
|
+
return this.#target;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Whether the target directory existed when the stage was created.
|
|
103
|
+
*
|
|
104
|
+
* @type {boolean}
|
|
105
|
+
*/
|
|
106
|
+
get isNew() {
|
|
107
|
+
return this.#isNew;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Compares the upper layer to the target.
|
|
112
|
+
*
|
|
113
|
+
* @returns {Promise<Change[]>}
|
|
114
|
+
*/
|
|
115
|
+
async changes() {
|
|
116
|
+
const staged = await listFiles(this.#directory);
|
|
117
|
+
const existing = existsSync(this.#target) ? await listFiles(this.#target) : new Set();
|
|
118
|
+
|
|
119
|
+
/** @type {Change[]} */
|
|
120
|
+
const changes = [];
|
|
121
|
+
|
|
122
|
+
for (const path of staged) {
|
|
123
|
+
if (!existing.has(path)) {
|
|
124
|
+
changes.push({ path, status: "added" });
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const [before, after] = await Promise.all([
|
|
129
|
+
readFile(join(this.#target, path)),
|
|
130
|
+
readFile(join(this.#directory, path)),
|
|
131
|
+
]);
|
|
132
|
+
|
|
133
|
+
if (!before.equals(after)) {
|
|
134
|
+
changes.push({ path, status: "modified" });
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
for (const path of existing) {
|
|
139
|
+
if (!staged.has(path)) {
|
|
140
|
+
changes.push({ path, status: "deleted" });
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return changes.sort((a, b) => a.path.localeCompare(b.path));
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* A unified diff of the staged changes against the target,
|
|
149
|
+
* colorized when stdout supports it.
|
|
150
|
+
*
|
|
151
|
+
* @param {Change[]} [changes] pass the result of {@link Stage#changes} to avoid recomputing
|
|
152
|
+
* @returns {Promise<string>}
|
|
153
|
+
*/
|
|
154
|
+
async diff(changes) {
|
|
155
|
+
changes ??= await this.changes();
|
|
156
|
+
|
|
157
|
+
const patches = [];
|
|
158
|
+
|
|
159
|
+
for (const change of changes) {
|
|
160
|
+
const before =
|
|
161
|
+
change.status === "added" ? null : await readFile(join(this.#target, change.path));
|
|
162
|
+
const after =
|
|
163
|
+
change.status === "deleted" ? null : await readFile(join(this.#directory, change.path));
|
|
164
|
+
|
|
165
|
+
patches.push(renderPatch(change, before, after));
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
return patches.join("\n");
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Applies the staged changes to the target directory, then removes the
|
|
173
|
+
* stage. Only paths reported by {@link Stage#changes} are touched --
|
|
174
|
+
* everything else in the target (untracked files, node_modules, .git)
|
|
175
|
+
* is left alone.
|
|
176
|
+
*
|
|
177
|
+
* One exception: when the stage has a `.git` directory and the target
|
|
178
|
+
* does not (the git layer ran `git init` inside the stage), the `.git`
|
|
179
|
+
* directory is carried over so a freshly generated project keeps its
|
|
180
|
+
* history.
|
|
181
|
+
*
|
|
182
|
+
* @param {Change[]} [changes] the changes to apply -- pass a subset of
|
|
183
|
+
* {@link Stage#changes} to apply only some of them (e.g. the ones a
|
|
184
|
+
* user accepted during review). Defaults to all staged changes.
|
|
185
|
+
* @returns {Promise<Change[]>} the changes that were written
|
|
186
|
+
*/
|
|
187
|
+
async commit(changes) {
|
|
188
|
+
changes ??= await this.changes();
|
|
189
|
+
|
|
190
|
+
await mkdir(this.#target, { recursive: true });
|
|
191
|
+
|
|
192
|
+
for (const change of changes) {
|
|
193
|
+
const targetPath = join(this.#target, change.path);
|
|
194
|
+
|
|
195
|
+
if (change.status === "deleted") {
|
|
196
|
+
await rm(targetPath, { force: true });
|
|
197
|
+
continue;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
await mkdir(dirname(targetPath), { recursive: true });
|
|
201
|
+
await cp(join(this.#directory, change.path), targetPath, { force: true });
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const stagedGit = join(this.#directory, ".git");
|
|
205
|
+
const targetGit = join(this.#target, ".git");
|
|
206
|
+
|
|
207
|
+
if (existsSync(stagedGit) && !existsSync(targetGit)) {
|
|
208
|
+
await cp(stagedGit, targetGit, { recursive: true });
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
await this.discard();
|
|
212
|
+
|
|
213
|
+
return changes;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Removes the stage without writing anything to the target.
|
|
218
|
+
*/
|
|
219
|
+
async discard() {
|
|
220
|
+
await rm(this.#directory, { recursive: true, force: true });
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* All files (as paths relative to `directory`), excluding environment
|
|
226
|
+
* directories. Includes dotfiles (`.gitignore`, `.github/`, ...), which
|
|
227
|
+
* `fs.glob` would skip.
|
|
228
|
+
*
|
|
229
|
+
* @param {string} directory
|
|
230
|
+
* @returns {Promise<Set<string>>}
|
|
231
|
+
*/
|
|
232
|
+
async function listFiles(directory) {
|
|
233
|
+
/** @type {Set<string>} */
|
|
234
|
+
const files = new Set();
|
|
235
|
+
|
|
236
|
+
const entries = await readdir(directory, { withFileTypes: true, recursive: true });
|
|
237
|
+
|
|
238
|
+
for (const entry of entries) {
|
|
239
|
+
if (!entry.isFile()) continue;
|
|
240
|
+
|
|
241
|
+
const path = relative(directory, join(entry.parentPath, entry.name));
|
|
242
|
+
|
|
243
|
+
if (path.split(sep).some((segment) => ENVIRONMENT_DIRECTORIES.has(segment))) {
|
|
244
|
+
continue;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
files.add(path);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
return files;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* @param {Change} change
|
|
255
|
+
* @param {Buffer | null} before
|
|
256
|
+
* @param {Buffer | null} after
|
|
257
|
+
* @returns {string}
|
|
258
|
+
*/
|
|
259
|
+
function renderPatch(change, before, after) {
|
|
260
|
+
const header = styleText("bold", `${statusLabel(change.status)} ${change.path}`);
|
|
261
|
+
|
|
262
|
+
if (before?.includes(0) || after?.includes(0)) {
|
|
263
|
+
return `${header}\n${styleText("dim", "(binary file)")}\n`;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const patch = structuredPatch(
|
|
267
|
+
change.path,
|
|
268
|
+
change.path,
|
|
269
|
+
before?.toString() ?? "",
|
|
270
|
+
after?.toString() ?? "",
|
|
271
|
+
undefined,
|
|
272
|
+
undefined,
|
|
273
|
+
{ context: 3 },
|
|
274
|
+
);
|
|
275
|
+
|
|
276
|
+
const lines = [
|
|
277
|
+
header,
|
|
278
|
+
styleText("red", `--- ${change.status === "added" ? "/dev/null" : `a/${change.path}`}`),
|
|
279
|
+
styleText("green", `+++ ${change.status === "deleted" ? "/dev/null" : `b/${change.path}`}`),
|
|
280
|
+
];
|
|
281
|
+
|
|
282
|
+
for (const hunk of patch.hunks) {
|
|
283
|
+
lines.push(
|
|
284
|
+
styleText(
|
|
285
|
+
"cyan",
|
|
286
|
+
`@@ -${hunk.oldStart},${hunk.oldLines} +${hunk.newStart},${hunk.newLines} @@`,
|
|
287
|
+
),
|
|
288
|
+
);
|
|
289
|
+
|
|
290
|
+
for (const line of hunk.lines) {
|
|
291
|
+
if (line.startsWith("+")) {
|
|
292
|
+
lines.push(styleText("green", line));
|
|
293
|
+
} else if (line.startsWith("-")) {
|
|
294
|
+
lines.push(styleText("red", line));
|
|
295
|
+
} else {
|
|
296
|
+
lines.push(line);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
return lines.join("\n") + "\n";
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* @param {ChangeStatus} status
|
|
306
|
+
* @returns {string}
|
|
307
|
+
*/
|
|
308
|
+
function statusLabel(status) {
|
|
309
|
+
switch (status) {
|
|
310
|
+
case "added":
|
|
311
|
+
return styleText("green", "A");
|
|
312
|
+
case "modified":
|
|
313
|
+
return styleText("yellow", "M");
|
|
314
|
+
case "deleted":
|
|
315
|
+
return styleText("red", "D");
|
|
316
|
+
}
|
|
317
|
+
}
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { execSync } from "node:child_process";
|
|
6
|
+
|
|
7
|
+
import { Stage } from "ember.nvp";
|
|
8
|
+
import { mktemp } from "#test-helpers";
|
|
9
|
+
|
|
10
|
+
async function makeTargetProject() {
|
|
11
|
+
const target = join(await mktemp("stage-target"), "my-app");
|
|
12
|
+
|
|
13
|
+
await mkdir(join(target, "app"), { recursive: true });
|
|
14
|
+
await mkdir(join(target, "node_modules/some-dep"), { recursive: true });
|
|
15
|
+
await writeFile(join(target, "package.json"), `{ "name": "my-app" }`);
|
|
16
|
+
await writeFile(join(target, "app/app.js"), `export default class App {}`);
|
|
17
|
+
await writeFile(join(target, "tsconfig.json"), `{}`);
|
|
18
|
+
await writeFile(join(target, "node_modules/some-dep/index.js"), `module.exports = 1;`);
|
|
19
|
+
|
|
20
|
+
return target;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
describe("Stage", () => {
|
|
24
|
+
describe("a new project (target does not exist)", () => {
|
|
25
|
+
it("stages writes without touching the target, then commits them", async () => {
|
|
26
|
+
const target = join(await mktemp("stage-new"), "my-app");
|
|
27
|
+
const stage = await Stage.create(target);
|
|
28
|
+
|
|
29
|
+
expect(stage.isNew).toBe(true);
|
|
30
|
+
|
|
31
|
+
await mkdir(join(stage.directory, "app"), { recursive: true });
|
|
32
|
+
await writeFile(join(stage.directory, "package.json"), `{ "name": "my-app" }`);
|
|
33
|
+
await writeFile(join(stage.directory, "app/app.js"), `export default class App {}`);
|
|
34
|
+
|
|
35
|
+
expect(existsSync(target), "target is untouched while staged").toBe(false);
|
|
36
|
+
|
|
37
|
+
const changes = await stage.changes();
|
|
38
|
+
|
|
39
|
+
expect(changes).toEqual([
|
|
40
|
+
{ path: "app/app.js", status: "added" },
|
|
41
|
+
{ path: "package.json", status: "added" },
|
|
42
|
+
]);
|
|
43
|
+
|
|
44
|
+
await stage.commit();
|
|
45
|
+
|
|
46
|
+
expect(await readFile(join(target, "package.json"), "utf-8")).toBe(`{ "name": "my-app" }`);
|
|
47
|
+
expect(existsSync(join(target, "app/app.js"))).toBe(true);
|
|
48
|
+
expect(existsSync(stage.directory), "stage is cleaned up after commit").toBe(false);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("carries a git repo initialized in the stage over to the target", async () => {
|
|
52
|
+
const target = join(await mktemp("stage-git"), "my-app");
|
|
53
|
+
const stage = await Stage.create(target);
|
|
54
|
+
|
|
55
|
+
await writeFile(join(stage.directory, "readme.md"), `hi`);
|
|
56
|
+
execSync("git init --initial-branch=main", { cwd: stage.directory, stdio: "pipe" });
|
|
57
|
+
|
|
58
|
+
await stage.commit();
|
|
59
|
+
|
|
60
|
+
expect(existsSync(join(target, ".git"))).toBe(true);
|
|
61
|
+
expect(existsSync(join(target, "readme.md"))).toBe(true);
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
describe("an existing project (seeded stage)", () => {
|
|
66
|
+
it("seeds the target's files, excluding node_modules and .git", async () => {
|
|
67
|
+
const target = await makeTargetProject();
|
|
68
|
+
execSync("git init --initial-branch=main", { cwd: target, stdio: "pipe" });
|
|
69
|
+
|
|
70
|
+
const stage = await Stage.create(target);
|
|
71
|
+
|
|
72
|
+
expect(stage.isNew).toBe(false);
|
|
73
|
+
expect(existsSync(join(stage.directory, "package.json"))).toBe(true);
|
|
74
|
+
expect(existsSync(join(stage.directory, "app/app.js"))).toBe(true);
|
|
75
|
+
expect(existsSync(join(stage.directory, "node_modules"))).toBe(false);
|
|
76
|
+
expect(existsSync(join(stage.directory, ".git"))).toBe(false);
|
|
77
|
+
|
|
78
|
+
await stage.discard();
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("detects added, modified, and deleted files", async () => {
|
|
82
|
+
const target = await makeTargetProject();
|
|
83
|
+
const stage = await Stage.create(target);
|
|
84
|
+
|
|
85
|
+
await writeFile(join(stage.directory, ".prettierrc.js"), `export default {};`);
|
|
86
|
+
await writeFile(join(stage.directory, "package.json"), `{ "name": "renamed" }`);
|
|
87
|
+
await rm(join(stage.directory, "tsconfig.json"));
|
|
88
|
+
|
|
89
|
+
const changes = await stage.changes();
|
|
90
|
+
|
|
91
|
+
expect(changes).toEqual([
|
|
92
|
+
{ path: ".prettierrc.js", status: "added" },
|
|
93
|
+
{ path: "package.json", status: "modified" },
|
|
94
|
+
{ path: "tsconfig.json", status: "deleted" },
|
|
95
|
+
]);
|
|
96
|
+
|
|
97
|
+
await stage.discard();
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("commit only touches changed paths, preserving node_modules and unrelated files", async () => {
|
|
101
|
+
const target = await makeTargetProject();
|
|
102
|
+
const stage = await Stage.create(target);
|
|
103
|
+
|
|
104
|
+
await writeFile(join(stage.directory, "package.json"), `{ "name": "renamed" }`);
|
|
105
|
+
await rm(join(stage.directory, "tsconfig.json"));
|
|
106
|
+
|
|
107
|
+
await stage.commit();
|
|
108
|
+
|
|
109
|
+
expect(await readFile(join(target, "package.json"), "utf-8")).toBe(`{ "name": "renamed" }`);
|
|
110
|
+
expect(
|
|
111
|
+
existsSync(join(target, "tsconfig.json")),
|
|
112
|
+
"deleted in stage -> deleted in target",
|
|
113
|
+
).toBe(false);
|
|
114
|
+
expect(existsSync(join(target, "app/app.js")), "unchanged files are untouched").toBe(true);
|
|
115
|
+
expect(
|
|
116
|
+
existsSync(join(target, "node_modules/some-dep/index.js")),
|
|
117
|
+
"node_modules survives commit",
|
|
118
|
+
).toBe(true);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("commit applies only the given subset of changes", async () => {
|
|
122
|
+
const target = await makeTargetProject();
|
|
123
|
+
const stage = await Stage.create(target);
|
|
124
|
+
|
|
125
|
+
await writeFile(join(stage.directory, ".prettierrc.js"), `export default {};`);
|
|
126
|
+
await writeFile(join(stage.directory, "package.json"), `{ "name": "renamed" }`);
|
|
127
|
+
await rm(join(stage.directory, "tsconfig.json"));
|
|
128
|
+
|
|
129
|
+
const changes = await stage.changes();
|
|
130
|
+
const accepted = changes.filter((change) => change.path !== "package.json");
|
|
131
|
+
|
|
132
|
+
await stage.commit(accepted);
|
|
133
|
+
|
|
134
|
+
expect(existsSync(join(target, ".prettierrc.js")), "accepted addition is written").toBe(true);
|
|
135
|
+
expect(existsSync(join(target, "tsconfig.json")), "accepted deletion is applied").toBe(false);
|
|
136
|
+
expect(
|
|
137
|
+
await readFile(join(target, "package.json"), "utf-8"),
|
|
138
|
+
"rejected change is not applied",
|
|
139
|
+
).toBe(`{ "name": "my-app" }`);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it("discard leaves the target exactly as it was", async () => {
|
|
143
|
+
const target = await makeTargetProject();
|
|
144
|
+
const stage = await Stage.create(target);
|
|
145
|
+
|
|
146
|
+
await writeFile(join(stage.directory, "package.json"), `{ "name": "renamed" }`);
|
|
147
|
+
|
|
148
|
+
await stage.discard();
|
|
149
|
+
|
|
150
|
+
expect(await readFile(join(target, "package.json"), "utf-8")).toBe(`{ "name": "my-app" }`);
|
|
151
|
+
expect(existsSync(stage.directory)).toBe(false);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it("reports no changes when nothing was modified", async () => {
|
|
155
|
+
const target = await makeTargetProject();
|
|
156
|
+
const stage = await Stage.create(target);
|
|
157
|
+
|
|
158
|
+
expect(await stage.changes()).toEqual([]);
|
|
159
|
+
|
|
160
|
+
await stage.discard();
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
describe("diff", () => {
|
|
165
|
+
it("renders a unified diff for added, modified, and deleted files", async () => {
|
|
166
|
+
const target = await makeTargetProject();
|
|
167
|
+
const stage = await Stage.create(target);
|
|
168
|
+
|
|
169
|
+
await writeFile(join(stage.directory, ".prettierrc.js"), `export default {};`);
|
|
170
|
+
await writeFile(join(stage.directory, "package.json"), `{ "name": "renamed" }`);
|
|
171
|
+
await rm(join(stage.directory, "tsconfig.json"));
|
|
172
|
+
|
|
173
|
+
const diff = await stage.diff();
|
|
174
|
+
|
|
175
|
+
expect(diff).toContain(`+++ b/.prettierrc.js`);
|
|
176
|
+
expect(diff).toContain(`+export default {};`);
|
|
177
|
+
|
|
178
|
+
expect(diff).toContain(`--- a/package.json`);
|
|
179
|
+
expect(diff).toContain(`-{ "name": "my-app" }`);
|
|
180
|
+
expect(diff).toContain(`+{ "name": "renamed" }`);
|
|
181
|
+
|
|
182
|
+
expect(diff).toContain(`--- a/tsconfig.json`);
|
|
183
|
+
expect(diff).toContain(`+++ /dev/null`);
|
|
184
|
+
|
|
185
|
+
await stage.discard();
|
|
186
|
+
});
|
|
187
|
+
});
|
|
188
|
+
});
|