exec-staged 0.1.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/LICENSE +19 -0
- package/README.md +177 -0
- package/dist/bin/cli.d.ts +2 -0
- package/dist/bin/cli.js +19 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/lib/config.d.ts +5 -0
- package/dist/lib/config.js +47 -0
- package/dist/lib/constants.d.ts +13 -0
- package/dist/lib/constants.js +17 -0
- package/dist/lib/exec_staged.d.ts +2 -0
- package/dist/lib/exec_staged.js +13 -0
- package/dist/lib/logger.d.ts +7 -0
- package/dist/lib/logger.js +23 -0
- package/dist/lib/spawn.d.ts +30 -0
- package/dist/lib/spawn.js +37 -0
- package/dist/lib/stage.d.ts +24 -0
- package/dist/lib/stage.js +300 -0
- package/dist/types.d.ts +11 -0
- package/dist/types.js +1 -0
- package/package.json +59 -0
- package/src/bin/cli.ts +26 -0
- package/src/index.ts +3 -0
- package/src/lib/config.ts +71 -0
- package/src/lib/constants.ts +24 -0
- package/src/lib/exec_staged.ts +19 -0
- package/src/lib/logger.ts +31 -0
- package/src/lib/spawn.ts +41 -0
- package/src/lib/stage.ts +376 -0
- package/src/types.ts +17 -0
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
import { BACKUP_STASH_MESSAGE, INTERPOLATION_IDENTIFIER, MERGE_FILES, STAGED_CHANGES_COMMIT_MESSAGE, stageLifecycleMessages, } from './constants.js';
|
|
2
|
+
import { Logger } from './logger.js';
|
|
3
|
+
import { spawn, spawnSync } from './spawn.js';
|
|
4
|
+
import micromatch from 'micromatch';
|
|
5
|
+
import fs from 'node:fs';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import semver from 'semver';
|
|
8
|
+
import parseArgsStringToArgv from 'string-argv';
|
|
9
|
+
export class Stage {
|
|
10
|
+
logger;
|
|
11
|
+
cwd;
|
|
12
|
+
stashed = false;
|
|
13
|
+
status = {};
|
|
14
|
+
mergeStatus = {};
|
|
15
|
+
head;
|
|
16
|
+
patchPath;
|
|
17
|
+
gitDir;
|
|
18
|
+
constructor(cwd, options = {}) {
|
|
19
|
+
this.cwd = cwd;
|
|
20
|
+
this.logger = new Logger(options.quiet);
|
|
21
|
+
this.logger.debug(`cwd: ${cwd}`);
|
|
22
|
+
}
|
|
23
|
+
async exec(tasks) {
|
|
24
|
+
try {
|
|
25
|
+
this.check();
|
|
26
|
+
this.prepare();
|
|
27
|
+
}
|
|
28
|
+
catch (error) {
|
|
29
|
+
this.logger.debug(error);
|
|
30
|
+
throw error;
|
|
31
|
+
}
|
|
32
|
+
try {
|
|
33
|
+
await this.run(tasks);
|
|
34
|
+
this.merge();
|
|
35
|
+
}
|
|
36
|
+
catch (error) {
|
|
37
|
+
this.logger.debug(error);
|
|
38
|
+
try {
|
|
39
|
+
this.revert();
|
|
40
|
+
}
|
|
41
|
+
catch (error) {
|
|
42
|
+
this.logger.debug(error);
|
|
43
|
+
}
|
|
44
|
+
throw error;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
check() {
|
|
48
|
+
this.logger.log(stageLifecycleMessages.check);
|
|
49
|
+
let version;
|
|
50
|
+
if (!fs.existsSync(this.cwd)) {
|
|
51
|
+
this.logger.log('⚠️ Directory does not exist!');
|
|
52
|
+
throw new Error('cwd does not exist');
|
|
53
|
+
}
|
|
54
|
+
try {
|
|
55
|
+
version = this.git(['--version']).match(/git version (\d+\.\d+\.\d+)/)?.[1];
|
|
56
|
+
}
|
|
57
|
+
catch (error) {
|
|
58
|
+
this.logger.log('⚠️ Git installation not found!');
|
|
59
|
+
throw error;
|
|
60
|
+
}
|
|
61
|
+
if (!version || semver.lte(version, '2.13.0')) {
|
|
62
|
+
this.logger.log('⚠️ Unsupported git version!');
|
|
63
|
+
throw new Error('unsupported git version');
|
|
64
|
+
}
|
|
65
|
+
let gitRootDirectory;
|
|
66
|
+
try {
|
|
67
|
+
gitRootDirectory = this.git(['rev-parse', '--show-toplevel']).trim();
|
|
68
|
+
}
|
|
69
|
+
catch (error) {
|
|
70
|
+
this.logger.log('⚠️ Not a git repository!');
|
|
71
|
+
throw new Error('cwd is not a git repository');
|
|
72
|
+
}
|
|
73
|
+
if (gitRootDirectory !== this.cwd) {
|
|
74
|
+
this.logger.log('⚠️ Not in git root directory!');
|
|
75
|
+
throw new Error('cwd is not a git repository root directory');
|
|
76
|
+
}
|
|
77
|
+
if (this.indexOfBackupStash() !== -1) {
|
|
78
|
+
this.logger.log('⚠️ Found unexpected backup stash!');
|
|
79
|
+
this.logger.log('It must be left over from a previous failed run. Remove it before proceeding.');
|
|
80
|
+
throw new Error('unexpected backup stash');
|
|
81
|
+
}
|
|
82
|
+
if (this.git(['log']).includes(STAGED_CHANGES_COMMIT_MESSAGE)) {
|
|
83
|
+
this.logger.log('⚠️ Found unexpected temporary commit!');
|
|
84
|
+
this.logger.log('It must be left over from a previous failed run. Remove it before proceeding.');
|
|
85
|
+
throw new Error('unexpected temporary commit');
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
prepare() {
|
|
89
|
+
this.logger.log(stageLifecycleMessages.prepare);
|
|
90
|
+
this.head = this.git(['rev-parse', 'HEAD']);
|
|
91
|
+
this.gitDir = this.git(['rev-parse', '--absolute-git-dir']);
|
|
92
|
+
this.patchPath = path.resolve(this.gitDir, 'patch.diff');
|
|
93
|
+
this.git(['status', '--porcelain'])
|
|
94
|
+
.split('\n')
|
|
95
|
+
.filter((f) => f.length)
|
|
96
|
+
.forEach((f) => (this.status[f.slice(3)] = f.slice(0, 2)));
|
|
97
|
+
// if there are no files in index or working tree, do not attempt to stash
|
|
98
|
+
if (Object.keys(this.status).length === 0)
|
|
99
|
+
return;
|
|
100
|
+
try {
|
|
101
|
+
this.logger.debug('➡️ ➡️ Creating patch of unstaged changes...');
|
|
102
|
+
const unstagedAdditions = Object.entries(this.status)
|
|
103
|
+
.filter(([, s]) => s.match(/^\?\?/))
|
|
104
|
+
.map(([f]) => f);
|
|
105
|
+
if (unstagedAdditions.length) {
|
|
106
|
+
this.git(['add', '--intent-to-add', '--', ...unstagedAdditions]);
|
|
107
|
+
}
|
|
108
|
+
this.git([
|
|
109
|
+
'diff',
|
|
110
|
+
'--binary',
|
|
111
|
+
'--default-prefix',
|
|
112
|
+
// skip deleted files because patch doesn't apply if they're modified
|
|
113
|
+
'--diff-filter=d',
|
|
114
|
+
'--no-color',
|
|
115
|
+
'--no-ext-diff',
|
|
116
|
+
'--no-rename-empty',
|
|
117
|
+
'--patch',
|
|
118
|
+
'--submodule=short',
|
|
119
|
+
'--unified=0',
|
|
120
|
+
'--output',
|
|
121
|
+
this.patchPath,
|
|
122
|
+
]);
|
|
123
|
+
if (unstagedAdditions.length) {
|
|
124
|
+
this.git(['reset', '--', ...unstagedAdditions]);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
catch (error) {
|
|
128
|
+
this.logger.log('⚠️ Error creating patch of unstaged changes!');
|
|
129
|
+
throw error;
|
|
130
|
+
}
|
|
131
|
+
try {
|
|
132
|
+
this.logger.debug('➡️ ➡️ Backing up merge status...');
|
|
133
|
+
this.backupMergeStatus();
|
|
134
|
+
}
|
|
135
|
+
catch (error) {
|
|
136
|
+
this.logger.log('⚠️ Error backing up merge status!');
|
|
137
|
+
throw error;
|
|
138
|
+
}
|
|
139
|
+
try {
|
|
140
|
+
this.logger.debug('➡️ ➡️ Creating backup stash and hiding unstaged changes...');
|
|
141
|
+
this.git([
|
|
142
|
+
'stash',
|
|
143
|
+
'push',
|
|
144
|
+
'--keep-index',
|
|
145
|
+
'--include-untracked',
|
|
146
|
+
'--message',
|
|
147
|
+
BACKUP_STASH_MESSAGE,
|
|
148
|
+
]);
|
|
149
|
+
this.stashed = true;
|
|
150
|
+
}
|
|
151
|
+
catch (error) {
|
|
152
|
+
this.logger.log('⚠️ Error creating backup stash!');
|
|
153
|
+
throw error;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
async run(tasks) {
|
|
157
|
+
this.logger.log(stageLifecycleMessages.run);
|
|
158
|
+
for (let i = 0; i < tasks.length; i++) {
|
|
159
|
+
const { task, diff, glob } = tasks[i];
|
|
160
|
+
try {
|
|
161
|
+
this.logger.log(`➡️ Running task ${i + 1} of ${tasks.length}: \`${task}\`...`);
|
|
162
|
+
const taskArgs = parseArgsStringToArgv(task);
|
|
163
|
+
const interpolationIndex = taskArgs.indexOf(INTERPOLATION_IDENTIFIER);
|
|
164
|
+
if (interpolationIndex !== -1) {
|
|
165
|
+
const files = micromatch(Object.entries(this.status)
|
|
166
|
+
.filter(([, s]) => s.match(new RegExp(`^[${diff}]`)))
|
|
167
|
+
.map(([f]) => f), glob);
|
|
168
|
+
if (files.length === 0) {
|
|
169
|
+
this.logger.log(`➡️ No matching files, skipping task...`);
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
taskArgs.splice(interpolationIndex, 1, ...files);
|
|
173
|
+
}
|
|
174
|
+
const { stdout } = await spawn(this.cwd, taskArgs);
|
|
175
|
+
this.logger.debug(stdout.replaceAll(/^/gm, '> '));
|
|
176
|
+
}
|
|
177
|
+
catch (error) {
|
|
178
|
+
this.logger.log(`⚠️ Error running task: \`${task}\`!`);
|
|
179
|
+
throw error;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
merge() {
|
|
184
|
+
this.logger.log(stageLifecycleMessages.merge);
|
|
185
|
+
try {
|
|
186
|
+
this.logger.debug('➡️ ➡️ Adding changes made by tasks...');
|
|
187
|
+
this.git(['add', '-A']);
|
|
188
|
+
}
|
|
189
|
+
catch (error) {
|
|
190
|
+
this.logger.log('⚠️ Error adding new changes!');
|
|
191
|
+
throw error;
|
|
192
|
+
}
|
|
193
|
+
if (!this.stashed)
|
|
194
|
+
return;
|
|
195
|
+
// attempt to retrieve the stash before running any damaging operations
|
|
196
|
+
const stash = this.findBackupStash();
|
|
197
|
+
try {
|
|
198
|
+
this.logger.debug('➡️ ➡️ Restoring unstaged changes from stash...');
|
|
199
|
+
// commit staged changes to keep them separate from unstaged changes in
|
|
200
|
+
// patch, because `--3-way` adds unstaged changes to the index
|
|
201
|
+
this.git([
|
|
202
|
+
'commit',
|
|
203
|
+
'--allow-empty',
|
|
204
|
+
'--no-verify',
|
|
205
|
+
'-m',
|
|
206
|
+
STAGED_CHANGES_COMMIT_MESSAGE,
|
|
207
|
+
]);
|
|
208
|
+
// apply patch containing unstaged changes
|
|
209
|
+
this.git([
|
|
210
|
+
'apply',
|
|
211
|
+
'--allow-empty',
|
|
212
|
+
'--recount',
|
|
213
|
+
'--unidiff-zero',
|
|
214
|
+
'--whitespace=nowarn',
|
|
215
|
+
'--3way',
|
|
216
|
+
this.patchPath,
|
|
217
|
+
]);
|
|
218
|
+
// unstaged deletions are not included in the patch and must be handled
|
|
219
|
+
// separately because the patch cannot be applied if such files are
|
|
220
|
+
// modified by tasks
|
|
221
|
+
Object.entries(this.status)
|
|
222
|
+
.filter(([, s]) => s.match(/^.D/))
|
|
223
|
+
.map(([f]) => f)
|
|
224
|
+
.forEach((f) => fs.rmSync(path.resolve(this.cwd, f)));
|
|
225
|
+
// make sure all restored unstaged changes are kept out of the index
|
|
226
|
+
this.git(['reset']);
|
|
227
|
+
// undo temporary commit while keeping its changes in the index
|
|
228
|
+
this.git(['reset', '--soft', this.head]);
|
|
229
|
+
// clean up
|
|
230
|
+
fs.rmSync(this.patchPath);
|
|
231
|
+
this.git(['stash', 'drop', stash]);
|
|
232
|
+
}
|
|
233
|
+
catch (error) {
|
|
234
|
+
this.logger.log('⚠️ Error restoring unstaged changes from stash!');
|
|
235
|
+
throw error;
|
|
236
|
+
}
|
|
237
|
+
this.restoreMergeStatus();
|
|
238
|
+
}
|
|
239
|
+
revert() {
|
|
240
|
+
this.logger.log(stageLifecycleMessages.revert);
|
|
241
|
+
let stash;
|
|
242
|
+
if (this.stashed) {
|
|
243
|
+
// attempt to retrieve the stash before running any damaging operations
|
|
244
|
+
stash = this.findBackupStash();
|
|
245
|
+
}
|
|
246
|
+
try {
|
|
247
|
+
this.logger.debug('➡️ ➡️ Reverting changes made by tasks...');
|
|
248
|
+
this.git(['add', '-A']);
|
|
249
|
+
this.git(['reset', '--hard', this.head]);
|
|
250
|
+
}
|
|
251
|
+
catch (error) {
|
|
252
|
+
this.logger.log('⚠️ Failed to revert changes made by tasks!');
|
|
253
|
+
throw error;
|
|
254
|
+
}
|
|
255
|
+
if (!this.stashed)
|
|
256
|
+
return;
|
|
257
|
+
try {
|
|
258
|
+
this.logger.debug('➡️ ➡️ Restoring state from backup stash...');
|
|
259
|
+
this.git(['stash', 'apply', '--index', stash]);
|
|
260
|
+
this.git(['stash', 'drop', stash]);
|
|
261
|
+
this.restoreMergeStatus();
|
|
262
|
+
}
|
|
263
|
+
catch (error) {
|
|
264
|
+
this.logger.log('⚠️ Failed to restore state from backup stash!');
|
|
265
|
+
throw error;
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
git(args) {
|
|
269
|
+
this.logger.debug(`git: ${args.map((arg) => `[${arg}]`).join(' ')}`);
|
|
270
|
+
const { stdout } = spawnSync(this.cwd, ['git', ...args]);
|
|
271
|
+
this.logger.debug(stdout.replaceAll(/^/gm, '> '));
|
|
272
|
+
return stdout;
|
|
273
|
+
}
|
|
274
|
+
backupMergeStatus() {
|
|
275
|
+
for (const mergeFile of MERGE_FILES) {
|
|
276
|
+
const file = path.resolve(this.gitDir, mergeFile);
|
|
277
|
+
if (fs.existsSync(file)) {
|
|
278
|
+
this.mergeStatus[mergeFile] = fs.readFileSync(file);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
restoreMergeStatus() {
|
|
283
|
+
for (const mergeFile of Object.keys(this.mergeStatus)) {
|
|
284
|
+
const contents = this.mergeStatus[mergeFile];
|
|
285
|
+
fs.writeFileSync(path.resolve(this.gitDir, mergeFile), contents);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
indexOfBackupStash() {
|
|
289
|
+
return this.git(['stash', 'list'])
|
|
290
|
+
.split('\n')
|
|
291
|
+
.findIndex((el) => el.includes(BACKUP_STASH_MESSAGE));
|
|
292
|
+
}
|
|
293
|
+
findBackupStash() {
|
|
294
|
+
const index = this.indexOfBackupStash();
|
|
295
|
+
if (index === -1) {
|
|
296
|
+
throw new Error('missing backup stash');
|
|
297
|
+
}
|
|
298
|
+
return `stash@{${index}}`;
|
|
299
|
+
}
|
|
300
|
+
}
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export type ExecStagedConfigEntry = {
|
|
2
|
+
task: string;
|
|
3
|
+
diff: string;
|
|
4
|
+
glob: string;
|
|
5
|
+
};
|
|
6
|
+
export type ExecStagedConfig = ExecStagedConfigEntry[];
|
|
7
|
+
export type ExecStagedUserConfigEntry = (Pick<ExecStagedConfigEntry, 'task'> & Partial<ExecStagedConfigEntry>) | ExecStagedConfigEntry['task'];
|
|
8
|
+
export type ExecStagedUserConfig = ExecStagedUserConfigEntry[];
|
|
9
|
+
export type StageOptions = {
|
|
10
|
+
quiet?: boolean;
|
|
11
|
+
};
|
package/dist/types.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/package.json
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "exec-staged",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Run commands against the current git index",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"git",
|
|
7
|
+
"lint",
|
|
8
|
+
"staged",
|
|
9
|
+
"lint-staged",
|
|
10
|
+
"pre-commit",
|
|
11
|
+
"commit",
|
|
12
|
+
"hook"
|
|
13
|
+
],
|
|
14
|
+
"repository": "github:ItsNickBarry/exec-staged",
|
|
15
|
+
"license": "MIT",
|
|
16
|
+
"author": "Nick Barry",
|
|
17
|
+
"type": "module",
|
|
18
|
+
"exports": {
|
|
19
|
+
".": "./dist/index.js",
|
|
20
|
+
"./types": "./dist/types.js"
|
|
21
|
+
},
|
|
22
|
+
"types": "./dist/index.d.ts",
|
|
23
|
+
"bin": {
|
|
24
|
+
"exec-staged": "./dist/bin/cli.js"
|
|
25
|
+
},
|
|
26
|
+
"files": [
|
|
27
|
+
"dist/",
|
|
28
|
+
"src/"
|
|
29
|
+
],
|
|
30
|
+
"dependencies": {
|
|
31
|
+
"commander": "^14.0.0",
|
|
32
|
+
"cosmiconfig": "^9.0.0",
|
|
33
|
+
"env-paths": "^3.0.0",
|
|
34
|
+
"execa": "^9.6.0",
|
|
35
|
+
"micromatch": "^4.0.8",
|
|
36
|
+
"on-process-exit": "^1.0.2",
|
|
37
|
+
"semver": "^7.7.2",
|
|
38
|
+
"string-argv": "^0.3.2"
|
|
39
|
+
},
|
|
40
|
+
"devDependencies": {
|
|
41
|
+
"@trivago/prettier-plugin-sort-imports": "^5.2.2",
|
|
42
|
+
"@tsconfig/node22": "^22.0.2",
|
|
43
|
+
"@types/micromatch": "^4.0.9",
|
|
44
|
+
"@types/node": "^22.15.31",
|
|
45
|
+
"@types/semver": "^7.7.0",
|
|
46
|
+
"husky": "^9.1.7",
|
|
47
|
+
"knip": "^5.60.2",
|
|
48
|
+
"lint-staged": "github:ItsNickBarry/lint-staged#knip",
|
|
49
|
+
"prettier": "^3.5.3",
|
|
50
|
+
"prettier-plugin-packagejson": "^2.5.15",
|
|
51
|
+
"tsx": "^4.20.1",
|
|
52
|
+
"typescript": "^5.8.3"
|
|
53
|
+
},
|
|
54
|
+
"scripts": {
|
|
55
|
+
"build": "pnpm clean && tsc --build",
|
|
56
|
+
"clean": "rm -rf dist/",
|
|
57
|
+
"test": "pnpm build && tsx --test --experimental-test-coverage"
|
|
58
|
+
}
|
|
59
|
+
}
|
package/src/bin/cli.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import pkg from '../../package.json' with { type: 'json' };
|
|
3
|
+
import { loadConfig } from '../lib/config.js';
|
|
4
|
+
import { execStaged } from '../lib/exec_staged.js';
|
|
5
|
+
import { program } from 'commander';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
|
|
8
|
+
program.name(pkg.name).version(pkg.version).description(pkg.description);
|
|
9
|
+
program.option('--quiet', 'suppress output');
|
|
10
|
+
program.option('--cwd <cwd>', 'directory in which to run');
|
|
11
|
+
program.argument('[tasks...]');
|
|
12
|
+
|
|
13
|
+
program.parse(process.argv);
|
|
14
|
+
|
|
15
|
+
const options = program.opts();
|
|
16
|
+
const args = program.args;
|
|
17
|
+
|
|
18
|
+
const cwd = path.resolve(options.cwd ?? '');
|
|
19
|
+
|
|
20
|
+
const tasks = args.length ? args : await loadConfig(cwd);
|
|
21
|
+
|
|
22
|
+
const result = await execStaged(cwd, tasks, options);
|
|
23
|
+
|
|
24
|
+
if (!result) {
|
|
25
|
+
process.exitCode ||= 1;
|
|
26
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import pkg from '../../package.json' with { type: 'json' };
|
|
2
|
+
import type {
|
|
3
|
+
ExecStagedConfig,
|
|
4
|
+
ExecStagedUserConfig,
|
|
5
|
+
ExecStagedUserConfigEntry,
|
|
6
|
+
} from '../types.js';
|
|
7
|
+
import { DEFAULT_CONFIG_ENTRY } from './constants.js';
|
|
8
|
+
import { cosmiconfig } from 'cosmiconfig';
|
|
9
|
+
|
|
10
|
+
export const loadConfig = async (
|
|
11
|
+
cwd: string,
|
|
12
|
+
): Promise<ExecStagedUserConfig> => {
|
|
13
|
+
const configResult = await cosmiconfig(pkg.name).search(cwd);
|
|
14
|
+
|
|
15
|
+
if (configResult) {
|
|
16
|
+
const { config, filepath } = configResult;
|
|
17
|
+
|
|
18
|
+
console.log(`Config loaded from ${filepath}`);
|
|
19
|
+
|
|
20
|
+
validateUserConfig(config);
|
|
21
|
+
|
|
22
|
+
return config;
|
|
23
|
+
} else {
|
|
24
|
+
console.log('No config found');
|
|
25
|
+
return [];
|
|
26
|
+
}
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export const resolveConfig = (
|
|
30
|
+
userConfig: ExecStagedUserConfig,
|
|
31
|
+
): ExecStagedConfig => {
|
|
32
|
+
return userConfig.map((entry) => ({
|
|
33
|
+
...DEFAULT_CONFIG_ENTRY,
|
|
34
|
+
...(typeof entry === 'string' ? { task: entry } : entry),
|
|
35
|
+
}));
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
/** @internal */
|
|
39
|
+
export const validateUserConfig = (userConfig: ExecStagedUserConfig) => {
|
|
40
|
+
if (!isValidUserConfig(userConfig)) {
|
|
41
|
+
throw new Error('invalid config');
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const isValidUserConfig = (userConfig: ExecStagedUserConfig): boolean => {
|
|
46
|
+
return (
|
|
47
|
+
Array.isArray(userConfig) &&
|
|
48
|
+
userConfig.every((userConfigEntry) =>
|
|
49
|
+
isValidUserConfigEntry(userConfigEntry),
|
|
50
|
+
)
|
|
51
|
+
);
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const isValidUserConfigEntry = (
|
|
55
|
+
userConfigEntry: ExecStagedUserConfigEntry,
|
|
56
|
+
): boolean => {
|
|
57
|
+
if (typeof userConfigEntry === 'string') return true;
|
|
58
|
+
if (typeof userConfigEntry !== 'object') return false;
|
|
59
|
+
if (typeof userConfigEntry.task !== 'string') return false;
|
|
60
|
+
if (
|
|
61
|
+
typeof userConfigEntry.diff !== 'string' &&
|
|
62
|
+
typeof userConfigEntry.diff !== 'undefined'
|
|
63
|
+
)
|
|
64
|
+
return false;
|
|
65
|
+
if (
|
|
66
|
+
typeof userConfigEntry.glob !== 'string' &&
|
|
67
|
+
typeof userConfigEntry.glob !== 'undefined'
|
|
68
|
+
)
|
|
69
|
+
return false;
|
|
70
|
+
return true;
|
|
71
|
+
};
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import pkg from '../../package.json' with { type: 'json' };
|
|
2
|
+
import type { ExecStagedConfigEntry } from '../types.js';
|
|
3
|
+
|
|
4
|
+
export const DEFAULT_CONFIG_ENTRY: Omit<ExecStagedConfigEntry, 'task'> = {
|
|
5
|
+
glob: '*',
|
|
6
|
+
diff: 'ACMR',
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
export const MERGE_FILES = ['MERGE_HEAD', 'MERGE_MODE', 'MERGE_MSG'] as const;
|
|
10
|
+
|
|
11
|
+
export const BACKUP_STASH_MESSAGE = `💾 ${pkg.name} backup stash`;
|
|
12
|
+
export const STAGED_CHANGES_COMMIT_MESSAGE = `💾 ${pkg.name} staged changes`;
|
|
13
|
+
|
|
14
|
+
export const INTERPOLATION_IDENTIFIER = '$FILES';
|
|
15
|
+
|
|
16
|
+
const PREFIX = '➡️ ';
|
|
17
|
+
|
|
18
|
+
export const stageLifecycleMessages = {
|
|
19
|
+
check: `${PREFIX}Checking environment...`,
|
|
20
|
+
prepare: `${PREFIX}Preparing repository...`,
|
|
21
|
+
run: `${PREFIX}Running tasks...`,
|
|
22
|
+
merge: `${PREFIX}Merging new changes with saved state...`,
|
|
23
|
+
revert: `${PREFIX}Reverting to saved state...`,
|
|
24
|
+
};
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { ExecStagedUserConfig, StageOptions } from '../types.js';
|
|
2
|
+
import { resolveConfig } from './config.js';
|
|
3
|
+
import { Stage } from './stage.js';
|
|
4
|
+
|
|
5
|
+
export const execStaged = async (
|
|
6
|
+
cwd: string,
|
|
7
|
+
tasks: ExecStagedUserConfig,
|
|
8
|
+
options: StageOptions = {},
|
|
9
|
+
): Promise<boolean> => {
|
|
10
|
+
const stage = new Stage(cwd, options);
|
|
11
|
+
|
|
12
|
+
try {
|
|
13
|
+
await stage.exec(resolveConfig(tasks));
|
|
14
|
+
return true;
|
|
15
|
+
} catch (error) {
|
|
16
|
+
console.log(`🪲 Log saved to: ${stage.logger.outFile}`);
|
|
17
|
+
return false;
|
|
18
|
+
}
|
|
19
|
+
};
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import pkg from '../../package.json' with { type: 'json' };
|
|
2
|
+
import envPaths from 'env-paths';
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
|
|
6
|
+
export class Logger {
|
|
7
|
+
public outFile: string;
|
|
8
|
+
private quiet: boolean;
|
|
9
|
+
|
|
10
|
+
constructor(quiet: boolean = false) {
|
|
11
|
+
this.quiet = quiet;
|
|
12
|
+
this.outFile = path.resolve(
|
|
13
|
+
envPaths(pkg.name).temp,
|
|
14
|
+
`debug-${new Date().getTime().toString()}-${crypto.randomUUID()}.txt`,
|
|
15
|
+
);
|
|
16
|
+
fs.mkdirSync(path.dirname(this.outFile), { recursive: true });
|
|
17
|
+
this.debug(`${pkg.name} log: ${new Date().toLocaleString()}`);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
public log(...params: Parameters<typeof console.log>): void {
|
|
21
|
+
this.debug(...params);
|
|
22
|
+
|
|
23
|
+
if (!this.quiet) {
|
|
24
|
+
console.log(...params);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
public debug(...params: Parameters<typeof console.debug>): void {
|
|
29
|
+
fs.appendFileSync(this.outFile, params.join('\n') + '\n');
|
|
30
|
+
}
|
|
31
|
+
}
|
package/src/lib/spawn.ts
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { execa, execaSync } from 'execa';
|
|
2
|
+
import { registerExitHandler, deregisterExitHandler } from 'on-process-exit';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Spawn a child process asynchronously using the `execa` package.
|
|
6
|
+
* This is used to run `exec-staged` tasks. The `execa` package is
|
|
7
|
+
* required because it provides the `preferLocal` option.
|
|
8
|
+
*
|
|
9
|
+
* Child processes are configured to be killed if the main process is stopped.
|
|
10
|
+
*
|
|
11
|
+
* @param cwd Directory where command should be executed.
|
|
12
|
+
* @param args Command string or array of command tokens.
|
|
13
|
+
* @throws ExecaError
|
|
14
|
+
* @returns Execa `Result` promise.
|
|
15
|
+
*/
|
|
16
|
+
export const spawn = async (cwd: string, args: string[]) => {
|
|
17
|
+
const subprocess = execa({
|
|
18
|
+
cwd,
|
|
19
|
+
preferLocal: true,
|
|
20
|
+
stdout: ['pipe', 'inherit'],
|
|
21
|
+
})(args[0], args.slice(1));
|
|
22
|
+
|
|
23
|
+
const id = registerExitHandler(() => subprocess.kill());
|
|
24
|
+
subprocess.once('close', () => deregisterExitHandler(id));
|
|
25
|
+
|
|
26
|
+
return subprocess;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Spawn a child process synchronously using the `execa` package.
|
|
31
|
+
* This is used to run `git` commands. A synchronous API is required because
|
|
32
|
+
* cleanup operations may be run via `on-process-exit`.
|
|
33
|
+
*
|
|
34
|
+
* @param cwd Directory where command should be executed.
|
|
35
|
+
* @param args Command string or array of command tokens.
|
|
36
|
+
* @throws ExecaSyncError
|
|
37
|
+
* @returns Execa `SyncResult`.
|
|
38
|
+
*/
|
|
39
|
+
export const spawnSync = (cwd: string, args: string[]) => {
|
|
40
|
+
return execaSync({ cwd })(args[0], args.slice(1));
|
|
41
|
+
};
|