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.
@@ -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
+ };