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
package/src/lib/stage.ts
ADDED
|
@@ -0,0 +1,376 @@
|
|
|
1
|
+
import type { ExecStagedConfig, StageOptions } from '../types.js';
|
|
2
|
+
import {
|
|
3
|
+
BACKUP_STASH_MESSAGE,
|
|
4
|
+
INTERPOLATION_IDENTIFIER,
|
|
5
|
+
MERGE_FILES,
|
|
6
|
+
STAGED_CHANGES_COMMIT_MESSAGE,
|
|
7
|
+
stageLifecycleMessages,
|
|
8
|
+
} from './constants.js';
|
|
9
|
+
import { Logger } from './logger.js';
|
|
10
|
+
import { spawn, spawnSync } from './spawn.js';
|
|
11
|
+
import micromatch from 'micromatch';
|
|
12
|
+
import fs from 'node:fs';
|
|
13
|
+
import path from 'node:path';
|
|
14
|
+
import semver from 'semver';
|
|
15
|
+
import parseArgsStringToArgv from 'string-argv';
|
|
16
|
+
|
|
17
|
+
export class Stage {
|
|
18
|
+
public readonly logger: Logger;
|
|
19
|
+
protected readonly cwd: string;
|
|
20
|
+
protected stashed: boolean = false;
|
|
21
|
+
private readonly status: { [file: string]: string } = {};
|
|
22
|
+
private readonly mergeStatus: {
|
|
23
|
+
[file in (typeof MERGE_FILES)[number]]?: Buffer;
|
|
24
|
+
} = {};
|
|
25
|
+
private head?: string;
|
|
26
|
+
private patchPath?: string;
|
|
27
|
+
private gitDir?: string;
|
|
28
|
+
|
|
29
|
+
constructor(cwd: string, options: StageOptions = {}) {
|
|
30
|
+
this.cwd = cwd;
|
|
31
|
+
this.logger = new Logger(options.quiet);
|
|
32
|
+
this.logger.debug(`cwd: ${cwd}`);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
public async exec(tasks: ExecStagedConfig) {
|
|
36
|
+
try {
|
|
37
|
+
this.check();
|
|
38
|
+
this.prepare();
|
|
39
|
+
} catch (error) {
|
|
40
|
+
this.logger.debug(error);
|
|
41
|
+
throw error;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
try {
|
|
45
|
+
await this.run(tasks);
|
|
46
|
+
this.merge();
|
|
47
|
+
} catch (error) {
|
|
48
|
+
this.logger.debug(error);
|
|
49
|
+
|
|
50
|
+
try {
|
|
51
|
+
this.revert();
|
|
52
|
+
} catch (error) {
|
|
53
|
+
this.logger.debug(error);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
throw error;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
protected check() {
|
|
61
|
+
this.logger.log(stageLifecycleMessages.check);
|
|
62
|
+
let version: string | undefined;
|
|
63
|
+
|
|
64
|
+
if (!fs.existsSync(this.cwd)) {
|
|
65
|
+
this.logger.log('⚠️ Directory does not exist!');
|
|
66
|
+
throw new Error('cwd does not exist');
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
try {
|
|
70
|
+
version = this.git(['--version']).match(
|
|
71
|
+
/git version (\d+\.\d+\.\d+)/,
|
|
72
|
+
)?.[1];
|
|
73
|
+
} catch (error) {
|
|
74
|
+
this.logger.log('⚠️ Git installation not found!');
|
|
75
|
+
throw error;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (!version || semver.lte(version, '2.13.0')) {
|
|
79
|
+
this.logger.log('⚠️ Unsupported git version!');
|
|
80
|
+
throw new Error('unsupported git version');
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
let gitRootDirectory: string;
|
|
84
|
+
|
|
85
|
+
try {
|
|
86
|
+
gitRootDirectory = this.git(['rev-parse', '--show-toplevel']).trim();
|
|
87
|
+
} catch (error) {
|
|
88
|
+
this.logger.log('⚠️ Not a git repository!');
|
|
89
|
+
throw new Error('cwd is not a git repository');
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (gitRootDirectory !== this.cwd) {
|
|
93
|
+
this.logger.log('⚠️ Not in git root directory!');
|
|
94
|
+
throw new Error('cwd is not a git repository root directory');
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (this.indexOfBackupStash() !== -1) {
|
|
98
|
+
this.logger.log('⚠️ Found unexpected backup stash!');
|
|
99
|
+
this.logger.log(
|
|
100
|
+
'It must be left over from a previous failed run. Remove it before proceeding.',
|
|
101
|
+
);
|
|
102
|
+
throw new Error('unexpected backup stash');
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (this.git(['log']).includes(STAGED_CHANGES_COMMIT_MESSAGE)) {
|
|
106
|
+
this.logger.log('⚠️ Found unexpected temporary commit!');
|
|
107
|
+
this.logger.log(
|
|
108
|
+
'It must be left over from a previous failed run. Remove it before proceeding.',
|
|
109
|
+
);
|
|
110
|
+
throw new Error('unexpected temporary commit');
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
protected prepare() {
|
|
115
|
+
this.logger.log(stageLifecycleMessages.prepare);
|
|
116
|
+
|
|
117
|
+
this.head = this.git(['rev-parse', 'HEAD']);
|
|
118
|
+
this.gitDir = this.git(['rev-parse', '--absolute-git-dir']);
|
|
119
|
+
this.patchPath = path.resolve(this.gitDir, 'patch.diff');
|
|
120
|
+
|
|
121
|
+
this.git(['status', '--porcelain'])
|
|
122
|
+
.split('\n')
|
|
123
|
+
.filter((f) => f.length)
|
|
124
|
+
.forEach((f) => (this.status[f.slice(3)] = f.slice(0, 2)));
|
|
125
|
+
|
|
126
|
+
// if there are no files in index or working tree, do not attempt to stash
|
|
127
|
+
if (Object.keys(this.status).length === 0) return;
|
|
128
|
+
|
|
129
|
+
try {
|
|
130
|
+
this.logger.debug('➡️ ➡️ Creating patch of unstaged changes...');
|
|
131
|
+
|
|
132
|
+
const unstagedAdditions = Object.entries(this.status)
|
|
133
|
+
.filter(([, s]) => s.match(/^\?\?/))
|
|
134
|
+
.map(([f]) => f);
|
|
135
|
+
|
|
136
|
+
if (unstagedAdditions.length) {
|
|
137
|
+
this.git(['add', '--intent-to-add', '--', ...unstagedAdditions]);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
this.git([
|
|
141
|
+
'diff',
|
|
142
|
+
'--binary',
|
|
143
|
+
'--default-prefix',
|
|
144
|
+
// skip deleted files because patch doesn't apply if they're modified
|
|
145
|
+
'--diff-filter=d',
|
|
146
|
+
'--no-color',
|
|
147
|
+
'--no-ext-diff',
|
|
148
|
+
'--no-rename-empty',
|
|
149
|
+
'--patch',
|
|
150
|
+
'--submodule=short',
|
|
151
|
+
'--unified=0',
|
|
152
|
+
'--output',
|
|
153
|
+
this.patchPath,
|
|
154
|
+
]);
|
|
155
|
+
|
|
156
|
+
if (unstagedAdditions.length) {
|
|
157
|
+
this.git(['reset', '--', ...unstagedAdditions]);
|
|
158
|
+
}
|
|
159
|
+
} catch (error) {
|
|
160
|
+
this.logger.log('⚠️ Error creating patch of unstaged changes!');
|
|
161
|
+
throw error;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
try {
|
|
165
|
+
this.logger.debug('➡️ ➡️ Backing up merge status...');
|
|
166
|
+
this.backupMergeStatus();
|
|
167
|
+
} catch (error) {
|
|
168
|
+
this.logger.log('⚠️ Error backing up merge status!');
|
|
169
|
+
throw error;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
try {
|
|
173
|
+
this.logger.debug(
|
|
174
|
+
'➡️ ➡️ Creating backup stash and hiding unstaged changes...',
|
|
175
|
+
);
|
|
176
|
+
|
|
177
|
+
this.git([
|
|
178
|
+
'stash',
|
|
179
|
+
'push',
|
|
180
|
+
'--keep-index',
|
|
181
|
+
'--include-untracked',
|
|
182
|
+
'--message',
|
|
183
|
+
BACKUP_STASH_MESSAGE,
|
|
184
|
+
]);
|
|
185
|
+
|
|
186
|
+
this.stashed = true;
|
|
187
|
+
} catch (error) {
|
|
188
|
+
this.logger.log('⚠️ Error creating backup stash!');
|
|
189
|
+
throw error;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
protected async run(tasks: ExecStagedConfig) {
|
|
194
|
+
this.logger.log(stageLifecycleMessages.run);
|
|
195
|
+
|
|
196
|
+
for (let i = 0; i < tasks.length; i++) {
|
|
197
|
+
const { task, diff, glob } = tasks[i];
|
|
198
|
+
|
|
199
|
+
try {
|
|
200
|
+
this.logger.log(
|
|
201
|
+
`➡️ Running task ${i + 1} of ${tasks.length}: \`${task}\`...`,
|
|
202
|
+
);
|
|
203
|
+
|
|
204
|
+
const taskArgs = parseArgsStringToArgv(task);
|
|
205
|
+
|
|
206
|
+
const interpolationIndex = taskArgs.indexOf(INTERPOLATION_IDENTIFIER);
|
|
207
|
+
|
|
208
|
+
if (interpolationIndex !== -1) {
|
|
209
|
+
const files = micromatch(
|
|
210
|
+
Object.entries(this.status)
|
|
211
|
+
.filter(([, s]) => s.match(new RegExp(`^[${diff}]`)))
|
|
212
|
+
.map(([f]) => f),
|
|
213
|
+
glob,
|
|
214
|
+
);
|
|
215
|
+
|
|
216
|
+
if (files.length === 0) {
|
|
217
|
+
this.logger.log(`➡️ No matching files, skipping task...`);
|
|
218
|
+
continue;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
taskArgs.splice(interpolationIndex, 1, ...files);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const { stdout } = await spawn(this.cwd, taskArgs);
|
|
225
|
+
|
|
226
|
+
this.logger.debug(stdout.replaceAll(/^/gm, '> '));
|
|
227
|
+
} catch (error) {
|
|
228
|
+
this.logger.log(`⚠️ Error running task: \`${task}\`!`);
|
|
229
|
+
throw error;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
protected merge() {
|
|
235
|
+
this.logger.log(stageLifecycleMessages.merge);
|
|
236
|
+
|
|
237
|
+
try {
|
|
238
|
+
this.logger.debug('➡️ ➡️ Adding changes made by tasks...');
|
|
239
|
+
this.git(['add', '-A']);
|
|
240
|
+
} catch (error) {
|
|
241
|
+
this.logger.log('⚠️ Error adding new changes!');
|
|
242
|
+
throw error;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
if (!this.stashed) return;
|
|
246
|
+
|
|
247
|
+
// attempt to retrieve the stash before running any damaging operations
|
|
248
|
+
const stash = this.findBackupStash();
|
|
249
|
+
|
|
250
|
+
try {
|
|
251
|
+
this.logger.debug('➡️ ➡️ Restoring unstaged changes from stash...');
|
|
252
|
+
|
|
253
|
+
// commit staged changes to keep them separate from unstaged changes in
|
|
254
|
+
// patch, because `--3-way` adds unstaged changes to the index
|
|
255
|
+
this.git([
|
|
256
|
+
'commit',
|
|
257
|
+
'--allow-empty',
|
|
258
|
+
'--no-verify',
|
|
259
|
+
'-m',
|
|
260
|
+
STAGED_CHANGES_COMMIT_MESSAGE,
|
|
261
|
+
]);
|
|
262
|
+
|
|
263
|
+
// apply patch containing unstaged changes
|
|
264
|
+
this.git([
|
|
265
|
+
'apply',
|
|
266
|
+
'--allow-empty',
|
|
267
|
+
'--recount',
|
|
268
|
+
'--unidiff-zero',
|
|
269
|
+
'--whitespace=nowarn',
|
|
270
|
+
'--3way',
|
|
271
|
+
this.patchPath!,
|
|
272
|
+
]);
|
|
273
|
+
|
|
274
|
+
// unstaged deletions are not included in the patch and must be handled
|
|
275
|
+
// separately because the patch cannot be applied if such files are
|
|
276
|
+
// modified by tasks
|
|
277
|
+
Object.entries(this.status)
|
|
278
|
+
.filter(([, s]) => s.match(/^.D/))
|
|
279
|
+
.map(([f]) => f)
|
|
280
|
+
.forEach((f) => fs.rmSync(path.resolve(this.cwd, f)));
|
|
281
|
+
|
|
282
|
+
// make sure all restored unstaged changes are kept out of the index
|
|
283
|
+
this.git(['reset']);
|
|
284
|
+
|
|
285
|
+
// undo temporary commit while keeping its changes in the index
|
|
286
|
+
this.git(['reset', '--soft', this.head!]);
|
|
287
|
+
|
|
288
|
+
// clean up
|
|
289
|
+
fs.rmSync(this.patchPath!);
|
|
290
|
+
this.git(['stash', 'drop', stash]);
|
|
291
|
+
} catch (error) {
|
|
292
|
+
this.logger.log('⚠️ Error restoring unstaged changes from stash!');
|
|
293
|
+
throw error;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
this.restoreMergeStatus();
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
protected revert() {
|
|
300
|
+
this.logger.log(stageLifecycleMessages.revert);
|
|
301
|
+
|
|
302
|
+
let stash: string | undefined;
|
|
303
|
+
|
|
304
|
+
if (this.stashed) {
|
|
305
|
+
// attempt to retrieve the stash before running any damaging operations
|
|
306
|
+
stash = this.findBackupStash();
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
try {
|
|
310
|
+
this.logger.debug('➡️ ➡️ Reverting changes made by tasks...');
|
|
311
|
+
|
|
312
|
+
this.git(['add', '-A']);
|
|
313
|
+
this.git(['reset', '--hard', this.head!]);
|
|
314
|
+
} catch (error) {
|
|
315
|
+
this.logger.log('⚠️ Failed to revert changes made by tasks!');
|
|
316
|
+
throw error;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
if (!this.stashed) return;
|
|
320
|
+
|
|
321
|
+
try {
|
|
322
|
+
this.logger.debug('➡️ ➡️ Restoring state from backup stash...');
|
|
323
|
+
|
|
324
|
+
this.git(['stash', 'apply', '--index', stash!]);
|
|
325
|
+
this.git(['stash', 'drop', stash!]);
|
|
326
|
+
this.restoreMergeStatus();
|
|
327
|
+
} catch (error) {
|
|
328
|
+
this.logger.log('⚠️ Failed to restore state from backup stash!');
|
|
329
|
+
throw error;
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
protected git(args: string[]): string {
|
|
334
|
+
this.logger.debug(`git: ${args.map((arg) => `[${arg}]`).join(' ')}`);
|
|
335
|
+
|
|
336
|
+
const { stdout } = spawnSync(this.cwd, ['git', ...args]);
|
|
337
|
+
|
|
338
|
+
this.logger.debug(stdout.replaceAll(/^/gm, '> '));
|
|
339
|
+
|
|
340
|
+
return stdout;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
private backupMergeStatus() {
|
|
344
|
+
for (const mergeFile of MERGE_FILES) {
|
|
345
|
+
const file = path.resolve(this.gitDir!, mergeFile);
|
|
346
|
+
if (fs.existsSync(file)) {
|
|
347
|
+
this.mergeStatus[mergeFile] = fs.readFileSync(file);
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
private restoreMergeStatus() {
|
|
353
|
+
for (const mergeFile of Object.keys(
|
|
354
|
+
this.mergeStatus,
|
|
355
|
+
) as (keyof typeof this.mergeStatus)[]) {
|
|
356
|
+
const contents = this.mergeStatus[mergeFile]!;
|
|
357
|
+
fs.writeFileSync(path.resolve(this.gitDir!, mergeFile), contents);
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
private indexOfBackupStash(): number {
|
|
362
|
+
return this.git(['stash', 'list'])
|
|
363
|
+
.split('\n')
|
|
364
|
+
.findIndex((el) => el.includes(BACKUP_STASH_MESSAGE));
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
private findBackupStash(): string {
|
|
368
|
+
const index = this.indexOfBackupStash();
|
|
369
|
+
|
|
370
|
+
if (index === -1) {
|
|
371
|
+
throw new Error('missing backup stash');
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
return `stash@{${index}}`;
|
|
375
|
+
}
|
|
376
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export type ExecStagedConfigEntry = {
|
|
2
|
+
task: string;
|
|
3
|
+
diff: string;
|
|
4
|
+
glob: string;
|
|
5
|
+
};
|
|
6
|
+
|
|
7
|
+
export type ExecStagedConfig = ExecStagedConfigEntry[];
|
|
8
|
+
|
|
9
|
+
export type ExecStagedUserConfigEntry =
|
|
10
|
+
| (Pick<ExecStagedConfigEntry, 'task'> & Partial<ExecStagedConfigEntry>)
|
|
11
|
+
| ExecStagedConfigEntry['task'];
|
|
12
|
+
|
|
13
|
+
export type ExecStagedUserConfig = ExecStagedUserConfigEntry[];
|
|
14
|
+
|
|
15
|
+
export type StageOptions = {
|
|
16
|
+
quiet?: boolean;
|
|
17
|
+
};
|