exec-staged 0.1.3 → 0.2.1
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/README.md +7 -13
- package/dist/bin/cli.js +24 -10
- package/dist/index.d.ts +2 -1
- package/dist/index.js +2 -1
- package/dist/lib/constants.d.ts +1 -0
- package/dist/lib/constants.js +1 -0
- package/dist/lib/exec_staged.d.ts +1 -0
- package/dist/lib/exec_staged.js +11 -0
- package/dist/lib/stage.d.ts +5 -2
- package/dist/lib/stage.js +81 -20
- package/package.json +1 -1
- package/src/bin/cli.ts +27 -11
- package/src/index.ts +2 -1
- package/src/lib/constants.ts +2 -0
- package/src/lib/exec_staged.ts +12 -0
- package/src/lib/stage.ts +113 -25
package/README.md
CHANGED
|
@@ -10,7 +10,7 @@ Run commands against files staged in git, ignoring unstaged changes and untracke
|
|
|
10
10
|
|
|
11
11
|
### How it Works
|
|
12
12
|
|
|
13
|
-
1. Unstaged changes and untracked files are hidden in a git stash. This
|
|
13
|
+
1. Unstaged changes and untracked files are hidden in a git stash. This includes unstaged deletions, which are temporarily restored.
|
|
14
14
|
2. User-configured tasks are run, with staged files passed in according to configuration.
|
|
15
15
|
3. Any changes made by tasks are added to the git index. This includes additions and deletions.
|
|
16
16
|
4. The stashed changes are restored.
|
|
@@ -131,7 +131,7 @@ Commands which include the `$FILES` token are only run if staged files are found
|
|
|
131
131
|
|
|
132
132
|
File filtering can be customized.
|
|
133
133
|
|
|
134
|
-
To filter files by name, add a `glob` filter (defaults to `'
|
|
134
|
+
To filter files by name, add a `glob` filter (defaults to `'**'`):
|
|
135
135
|
|
|
136
136
|
```typescript
|
|
137
137
|
{ task: 'prettier --write $FILES', glob: '*.{js,ts,json,md}' }
|
|
@@ -155,21 +155,15 @@ Before running any potentially destructive scripts, `exec-staged` stores all out
|
|
|
155
155
|
|
|
156
156
|
### Recovery
|
|
157
157
|
|
|
158
|
-
If `exec-staged` fails to exit safely, such as due to power loss or if its process is killed via `SIGKILL`,
|
|
159
|
-
|
|
160
|
-
To verify, run `git log` and look for a stash with the message `💾 exec-staged backup stash`. It should be the most recent stash. If it isn't, one of your tasks probably created a stash for some reason. This is very unlikely. Remove any such stashes before proceeding.
|
|
161
|
-
|
|
162
|
-
`exec-staged` also creates a short-lived temporary commit with the message `💾 exec-staged staged changes`. If it's present, it can be removed with `git reset --hard HEAD~1`.
|
|
163
|
-
|
|
164
|
-
The following commands should return your repository to its original state:
|
|
158
|
+
If `exec-staged` fails to exit safely, such as due to power loss or if its process is killed via `SIGKILL`, run the recovery tool to restore your repository to its original state:
|
|
165
159
|
|
|
166
160
|
```bash
|
|
167
|
-
|
|
168
|
-
git reset --hard HEAD
|
|
169
|
-
git stash pop --index
|
|
161
|
+
npx exec-staged recover
|
|
170
162
|
```
|
|
171
163
|
|
|
172
|
-
|
|
164
|
+
This will automatically find and apply the backup stash, restore any merge metadata, and clean up leftover artifacts.
|
|
165
|
+
|
|
166
|
+
To prevent data loss, `exec-staged` will not run if artifacts from a previous run are present.
|
|
173
167
|
|
|
174
168
|
## See Also
|
|
175
169
|
|
package/dist/bin/cli.js
CHANGED
|
@@ -1,19 +1,33 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import pkg from '../../package.json' with { type: 'json' };
|
|
3
3
|
import { loadConfig } from '../lib/config.js';
|
|
4
|
-
import { execStaged } from '../lib/exec_staged.js';
|
|
4
|
+
import { execStaged, recoverStaged } from '../lib/exec_staged.js';
|
|
5
5
|
import { program } from 'commander';
|
|
6
6
|
import path from 'node:path';
|
|
7
7
|
program.name(pkg.name).version(pkg.version).description(pkg.description);
|
|
8
8
|
program.option('--quiet', 'suppress output');
|
|
9
9
|
program.option('--cwd <cwd>', 'directory in which to run');
|
|
10
|
-
program
|
|
10
|
+
program
|
|
11
|
+
.command('run', { isDefault: true })
|
|
12
|
+
.argument('[tasks...]')
|
|
13
|
+
.action(async (args) => {
|
|
14
|
+
const options = program.opts();
|
|
15
|
+
const cwd = path.resolve(options.cwd ?? '');
|
|
16
|
+
const tasks = args.length ? args : await loadConfig(cwd);
|
|
17
|
+
const result = await execStaged(cwd, tasks, options);
|
|
18
|
+
if (!result) {
|
|
19
|
+
process.exitCode ||= 1;
|
|
20
|
+
}
|
|
21
|
+
});
|
|
22
|
+
program
|
|
23
|
+
.command('recover')
|
|
24
|
+
.description('Recover from a failed exec-staged run')
|
|
25
|
+
.action(() => {
|
|
26
|
+
const options = program.opts();
|
|
27
|
+
const cwd = path.resolve(options.cwd ?? '');
|
|
28
|
+
const result = recoverStaged(cwd);
|
|
29
|
+
if (!result) {
|
|
30
|
+
process.exitCode ||= 1;
|
|
31
|
+
}
|
|
32
|
+
});
|
|
11
33
|
program.parse(process.argv);
|
|
12
|
-
const options = program.opts();
|
|
13
|
-
const args = program.args;
|
|
14
|
-
const cwd = path.resolve(options.cwd ?? '');
|
|
15
|
-
const tasks = args.length ? args : await loadConfig(cwd);
|
|
16
|
-
const result = await execStaged(cwd, tasks, options);
|
|
17
|
-
if (!result) {
|
|
18
|
-
process.exitCode ||= 1;
|
|
19
|
-
}
|
package/dist/index.d.ts
CHANGED
package/dist/index.js
CHANGED
package/dist/lib/constants.d.ts
CHANGED
|
@@ -3,6 +3,7 @@ export declare const DEFAULT_CONFIG_ENTRY: Omit<ExecStagedConfigEntry, 'task'>;
|
|
|
3
3
|
export declare const MERGE_FILES: readonly ["MERGE_HEAD", "MERGE_MODE", "MERGE_MSG"];
|
|
4
4
|
export declare const BACKUP_STASH_MESSAGE: string;
|
|
5
5
|
export declare const STAGED_CHANGES_COMMIT_MESSAGE: string;
|
|
6
|
+
export declare const ARTIFACTS_DIRECTORY: string;
|
|
6
7
|
export declare const INTERPOLATION_IDENTIFIER = "$FILES";
|
|
7
8
|
export declare const stageLifecycleMessages: {
|
|
8
9
|
check: string;
|
package/dist/lib/constants.js
CHANGED
|
@@ -6,6 +6,7 @@ export const DEFAULT_CONFIG_ENTRY = {
|
|
|
6
6
|
export const MERGE_FILES = ['MERGE_HEAD', 'MERGE_MODE', 'MERGE_MSG'];
|
|
7
7
|
export const BACKUP_STASH_MESSAGE = `💾 ${pkg.name} backup stash`;
|
|
8
8
|
export const STAGED_CHANGES_COMMIT_MESSAGE = `💾 ${pkg.name} staged changes`;
|
|
9
|
+
export const ARTIFACTS_DIRECTORY = `.${pkg.name}`;
|
|
9
10
|
export const INTERPOLATION_IDENTIFIER = '$FILES';
|
|
10
11
|
const PREFIX = '➡️ ';
|
|
11
12
|
export const stageLifecycleMessages = {
|
package/dist/lib/exec_staged.js
CHANGED
|
@@ -13,3 +13,14 @@ export const execStaged = async (cwd, tasks, options = {}) => {
|
|
|
13
13
|
return false;
|
|
14
14
|
}
|
|
15
15
|
};
|
|
16
|
+
export const recoverStaged = (cwd) => {
|
|
17
|
+
const stage = new Stage(cwd);
|
|
18
|
+
try {
|
|
19
|
+
stage.recover();
|
|
20
|
+
return true;
|
|
21
|
+
}
|
|
22
|
+
catch (error) {
|
|
23
|
+
console.log(`🪲 Log saved to: ${stage.logger.outFile}`);
|
|
24
|
+
return false;
|
|
25
|
+
}
|
|
26
|
+
};
|
package/dist/lib/stage.d.ts
CHANGED
|
@@ -7,8 +7,10 @@ export declare class Stage {
|
|
|
7
7
|
private readonly status;
|
|
8
8
|
private readonly mergeStatus;
|
|
9
9
|
private head?;
|
|
10
|
-
private
|
|
11
|
-
private gitDir
|
|
10
|
+
private _gitDir?;
|
|
11
|
+
private get gitDir();
|
|
12
|
+
private get artifactsDir();
|
|
13
|
+
private get patchPath();
|
|
12
14
|
constructor(cwd: string, options?: StageOptions);
|
|
13
15
|
exec(tasks: ExecStagedConfig): Promise<void>;
|
|
14
16
|
protected check(): void;
|
|
@@ -21,4 +23,5 @@ export declare class Stage {
|
|
|
21
23
|
private restoreMergeStatus;
|
|
22
24
|
private indexOfBackupStash;
|
|
23
25
|
private findBackupStash;
|
|
26
|
+
recover(): void;
|
|
24
27
|
}
|
package/dist/lib/stage.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { BACKUP_STASH_MESSAGE, INTERPOLATION_IDENTIFIER, MERGE_FILES, STAGED_CHANGES_COMMIT_MESSAGE, stageLifecycleMessages, } from './constants.js';
|
|
1
|
+
import { ARTIFACTS_DIRECTORY, BACKUP_STASH_MESSAGE, INTERPOLATION_IDENTIFIER, MERGE_FILES, STAGED_CHANGES_COMMIT_MESSAGE, stageLifecycleMessages, } from './constants.js';
|
|
2
2
|
import { Logger } from './logger.js';
|
|
3
3
|
import { spawn, spawnSync } from './spawn.js';
|
|
4
4
|
import micromatch from 'micromatch';
|
|
@@ -11,10 +11,18 @@ export class Stage {
|
|
|
11
11
|
cwd;
|
|
12
12
|
stashed = false;
|
|
13
13
|
status = {};
|
|
14
|
-
mergeStatus =
|
|
14
|
+
mergeStatus = [];
|
|
15
15
|
head;
|
|
16
|
-
|
|
17
|
-
gitDir
|
|
16
|
+
_gitDir;
|
|
17
|
+
get gitDir() {
|
|
18
|
+
return (this._gitDir ??= this.git(['rev-parse', '--absolute-git-dir']));
|
|
19
|
+
}
|
|
20
|
+
get artifactsDir() {
|
|
21
|
+
return path.resolve(this.gitDir, ARTIFACTS_DIRECTORY);
|
|
22
|
+
}
|
|
23
|
+
get patchPath() {
|
|
24
|
+
return path.resolve(this.artifactsDir, 'patch.diff');
|
|
25
|
+
}
|
|
18
26
|
constructor(cwd, options = {}) {
|
|
19
27
|
this.cwd = cwd;
|
|
20
28
|
this.logger = new Logger(options.quiet);
|
|
@@ -56,7 +64,7 @@ export class Stage {
|
|
|
56
64
|
}
|
|
57
65
|
catch (error) {
|
|
58
66
|
this.logger.log('⚠️ Git installation not found!');
|
|
59
|
-
throw
|
|
67
|
+
throw new Error('git installation not found');
|
|
60
68
|
}
|
|
61
69
|
if (!version || semver.lte(version, '2.13.0')) {
|
|
62
70
|
this.logger.log('⚠️ Unsupported git version!');
|
|
@@ -74,12 +82,32 @@ export class Stage {
|
|
|
74
82
|
this.logger.log('⚠️ Not in git root directory!');
|
|
75
83
|
throw new Error('cwd is not a git repository root directory');
|
|
76
84
|
}
|
|
85
|
+
// --absolute-git-dir resolves symlinks, --git-dir does not; if they
|
|
86
|
+
// differ, .git involves symlinks; walk the chain and reject if any
|
|
87
|
+
// target is inside the repo, because git stash --include-untracked
|
|
88
|
+
// would stash (and remove) it, breaking all subsequent operations
|
|
89
|
+
let gitDirPath = path.resolve(this.cwd, this.git(['rev-parse', '--git-dir']));
|
|
90
|
+
while (fs.lstatSync(gitDirPath).isSymbolicLink()) {
|
|
91
|
+
// readlink may return a relative path, so resolve against the
|
|
92
|
+
// symlink's parent directory to get an absolute path
|
|
93
|
+
const target = fs.readlinkSync(gitDirPath);
|
|
94
|
+
gitDirPath = path.resolve(path.dirname(gitDirPath), target);
|
|
95
|
+
if (!path.relative(this.cwd, gitDirPath).startsWith('..')) {
|
|
96
|
+
this.logger.log('⚠️ Git directory is a symlink pointing to a location within the repository!');
|
|
97
|
+
throw new Error('git directory is a symlink pointing to a location within the repository');
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
if (fs.existsSync(this.artifactsDir)) {
|
|
101
|
+
this.logger.log('⚠️ Found unexpected artifacts directory!');
|
|
102
|
+
this.logger.log('It must be left over from a previous failed run. Run `exec-staged recover` to restore your repository.');
|
|
103
|
+
throw new Error('unexpected artifacts directory');
|
|
104
|
+
}
|
|
77
105
|
if (this.indexOfBackupStash() !== -1) {
|
|
78
106
|
this.logger.log('⚠️ Found unexpected backup stash!');
|
|
79
|
-
this.logger.log('It must be left over from a previous failed run.
|
|
107
|
+
this.logger.log('It must be left over from a previous failed run. Run `exec-staged recover` to restore your repository.');
|
|
80
108
|
throw new Error('unexpected backup stash');
|
|
81
109
|
}
|
|
82
|
-
if (this.git(['log'])
|
|
110
|
+
if (this.git(['log', '--grep', STAGED_CHANGES_COMMIT_MESSAGE, '--format=%s'])) {
|
|
83
111
|
this.logger.log('⚠️ Found unexpected temporary commit!');
|
|
84
112
|
this.logger.log('It must be left over from a previous failed run. Remove it before proceeding.');
|
|
85
113
|
throw new Error('unexpected temporary commit');
|
|
@@ -88,8 +116,6 @@ export class Stage {
|
|
|
88
116
|
prepare() {
|
|
89
117
|
this.logger.log(stageLifecycleMessages.prepare);
|
|
90
118
|
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
119
|
this.git(['status', '--porcelain', '--no-renames'])
|
|
94
120
|
.split('\n')
|
|
95
121
|
.filter((f) => f.length)
|
|
@@ -97,6 +123,7 @@ export class Stage {
|
|
|
97
123
|
// if there are no files in index or working tree, do not attempt to stash
|
|
98
124
|
if (Object.keys(this.status).length === 0)
|
|
99
125
|
return;
|
|
126
|
+
fs.mkdirSync(this.artifactsDir);
|
|
100
127
|
try {
|
|
101
128
|
this.logger.debug('➡️ ➡️ Creating patch of unstaged changes...');
|
|
102
129
|
const unstagedAdditions = Object.entries(this.status)
|
|
@@ -164,7 +191,11 @@ export class Stage {
|
|
|
164
191
|
if (interpolationIndex !== -1) {
|
|
165
192
|
const files = micromatch(Object.entries(this.status)
|
|
166
193
|
.filter(([, s]) => s.match(new RegExp(`^[${diff}]`)))
|
|
167
|
-
.map(([f]) => f), glob, {
|
|
194
|
+
.map(([f]) => f), glob, {
|
|
195
|
+
dot: true,
|
|
196
|
+
// match filenames in any directory when glob has no path separators
|
|
197
|
+
matchBase: !glob.includes('/'),
|
|
198
|
+
});
|
|
168
199
|
if (files.length === 0) {
|
|
169
200
|
this.logger.log(`➡️ No matching files, skipping task...`);
|
|
170
201
|
continue;
|
|
@@ -217,24 +248,25 @@ export class Stage {
|
|
|
217
248
|
]);
|
|
218
249
|
// unstaged deletions are not included in the patch and must be handled
|
|
219
250
|
// separately because the patch cannot be applied if such files are
|
|
220
|
-
// modified by tasks
|
|
251
|
+
// modified by tasks; use force: true to handle cases where the file
|
|
252
|
+
// doesn't exist (e.g., unstaged renames)
|
|
221
253
|
Object.entries(this.status)
|
|
222
254
|
.filter(([, s]) => s.match(/^.D/))
|
|
223
255
|
.map(([f]) => f)
|
|
224
|
-
.forEach((f) => fs.rmSync(path.resolve(this.cwd, f)));
|
|
256
|
+
.forEach((f) => fs.rmSync(path.resolve(this.cwd, f), { force: true }));
|
|
225
257
|
// make sure all restored unstaged changes are kept out of the index
|
|
226
258
|
this.git(['reset']);
|
|
227
259
|
// undo temporary commit while keeping its changes in the index
|
|
228
260
|
this.git(['reset', '--soft', this.head]);
|
|
229
261
|
// clean up
|
|
230
|
-
|
|
262
|
+
this.restoreMergeStatus();
|
|
263
|
+
fs.rmSync(this.artifactsDir, { recursive: true });
|
|
231
264
|
this.git(['stash', 'drop', stash]);
|
|
232
265
|
}
|
|
233
266
|
catch (error) {
|
|
234
267
|
this.logger.log('⚠️ Error restoring unstaged changes from stash!');
|
|
235
268
|
throw error;
|
|
236
269
|
}
|
|
237
|
-
this.restoreMergeStatus();
|
|
238
270
|
}
|
|
239
271
|
revert() {
|
|
240
272
|
this.logger.log(stageLifecycleMessages.revert);
|
|
@@ -259,6 +291,7 @@ export class Stage {
|
|
|
259
291
|
this.git(['stash', 'apply', '--index', stash]);
|
|
260
292
|
this.git(['stash', 'drop', stash]);
|
|
261
293
|
this.restoreMergeStatus();
|
|
294
|
+
fs.rmSync(this.artifactsDir, { recursive: true });
|
|
262
295
|
}
|
|
263
296
|
catch (error) {
|
|
264
297
|
this.logger.log('⚠️ Failed to restore state from backup stash!');
|
|
@@ -273,16 +306,16 @@ export class Stage {
|
|
|
273
306
|
}
|
|
274
307
|
backupMergeStatus() {
|
|
275
308
|
for (const mergeFile of MERGE_FILES) {
|
|
276
|
-
const
|
|
277
|
-
if (fs.existsSync(
|
|
278
|
-
|
|
309
|
+
const src = path.resolve(this.gitDir, mergeFile);
|
|
310
|
+
if (fs.existsSync(src)) {
|
|
311
|
+
fs.copyFileSync(src, path.resolve(this.artifactsDir, mergeFile));
|
|
312
|
+
this.mergeStatus.push(mergeFile);
|
|
279
313
|
}
|
|
280
314
|
}
|
|
281
315
|
}
|
|
282
316
|
restoreMergeStatus() {
|
|
283
|
-
for (const mergeFile of
|
|
284
|
-
|
|
285
|
-
fs.writeFileSync(path.resolve(this.gitDir, mergeFile), contents);
|
|
317
|
+
for (const mergeFile of this.mergeStatus) {
|
|
318
|
+
fs.renameSync(path.resolve(this.artifactsDir, mergeFile), path.resolve(this.gitDir, mergeFile));
|
|
286
319
|
}
|
|
287
320
|
}
|
|
288
321
|
indexOfBackupStash() {
|
|
@@ -297,4 +330,32 @@ export class Stage {
|
|
|
297
330
|
}
|
|
298
331
|
return `stash@{${index}}`;
|
|
299
332
|
}
|
|
333
|
+
recover() {
|
|
334
|
+
const stashIndex = this.indexOfBackupStash();
|
|
335
|
+
const stashFound = stashIndex !== -1;
|
|
336
|
+
const artifactsFound = fs.existsSync(this.artifactsDir);
|
|
337
|
+
if (!stashFound && !artifactsFound) {
|
|
338
|
+
this.logger.log('➡️ Nothing to recover.');
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
if (stashFound) {
|
|
342
|
+
const stash = `stash@{${stashIndex}}`;
|
|
343
|
+
this.logger.log('➡️ Found backup stash, restoring...');
|
|
344
|
+
const head = this.git(['rev-parse', `${stash}^1`]);
|
|
345
|
+
this.git(['add', '-A']);
|
|
346
|
+
this.git(['reset', '--hard', head]);
|
|
347
|
+
this.git(['stash', 'apply', '--index', stash]);
|
|
348
|
+
this.git(['stash', 'drop', stash]);
|
|
349
|
+
}
|
|
350
|
+
if (artifactsFound) {
|
|
351
|
+
this.logger.log('➡️ Found artifacts directory, cleaning up...');
|
|
352
|
+
for (const mergeFile of MERGE_FILES) {
|
|
353
|
+
const src = path.resolve(this.artifactsDir, mergeFile);
|
|
354
|
+
if (fs.existsSync(src)) {
|
|
355
|
+
fs.renameSync(src, path.resolve(this.gitDir, mergeFile));
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
fs.rmSync(this.artifactsDir, { recursive: true });
|
|
359
|
+
}
|
|
360
|
+
}
|
|
300
361
|
}
|
package/package.json
CHANGED
package/src/bin/cli.ts
CHANGED
|
@@ -1,26 +1,42 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import pkg from '../../package.json' with { type: 'json' };
|
|
3
3
|
import { loadConfig } from '../lib/config.js';
|
|
4
|
-
import { execStaged } from '../lib/exec_staged.js';
|
|
4
|
+
import { execStaged, recoverStaged } from '../lib/exec_staged.js';
|
|
5
5
|
import { program } from 'commander';
|
|
6
6
|
import path from 'node:path';
|
|
7
7
|
|
|
8
8
|
program.name(pkg.name).version(pkg.version).description(pkg.description);
|
|
9
9
|
program.option('--quiet', 'suppress output');
|
|
10
10
|
program.option('--cwd <cwd>', 'directory in which to run');
|
|
11
|
-
program.argument('[tasks...]');
|
|
12
11
|
|
|
13
|
-
program
|
|
12
|
+
program
|
|
13
|
+
.command('run', { isDefault: true })
|
|
14
|
+
.argument('[tasks...]')
|
|
15
|
+
.action(async (args: string[]) => {
|
|
16
|
+
const options = program.opts();
|
|
17
|
+
const cwd = path.resolve(options.cwd ?? '');
|
|
18
|
+
|
|
19
|
+
const tasks = args.length ? args : await loadConfig(cwd);
|
|
14
20
|
|
|
15
|
-
const
|
|
16
|
-
const args = program.args;
|
|
21
|
+
const result = await execStaged(cwd, tasks, options);
|
|
17
22
|
|
|
18
|
-
|
|
23
|
+
if (!result) {
|
|
24
|
+
process.exitCode ||= 1;
|
|
25
|
+
}
|
|
26
|
+
});
|
|
19
27
|
|
|
20
|
-
|
|
28
|
+
program
|
|
29
|
+
.command('recover')
|
|
30
|
+
.description('Recover from a failed exec-staged run')
|
|
31
|
+
.action(() => {
|
|
32
|
+
const options = program.opts();
|
|
33
|
+
const cwd = path.resolve(options.cwd ?? '');
|
|
21
34
|
|
|
22
|
-
const result =
|
|
35
|
+
const result = recoverStaged(cwd);
|
|
23
36
|
|
|
24
|
-
if (!result) {
|
|
25
|
-
|
|
26
|
-
}
|
|
37
|
+
if (!result) {
|
|
38
|
+
process.exitCode ||= 1;
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
program.parse(process.argv);
|
package/src/index.ts
CHANGED
package/src/lib/constants.ts
CHANGED
|
@@ -11,6 +11,8 @@ export const MERGE_FILES = ['MERGE_HEAD', 'MERGE_MODE', 'MERGE_MSG'] as const;
|
|
|
11
11
|
export const BACKUP_STASH_MESSAGE = `💾 ${pkg.name} backup stash`;
|
|
12
12
|
export const STAGED_CHANGES_COMMIT_MESSAGE = `💾 ${pkg.name} staged changes`;
|
|
13
13
|
|
|
14
|
+
export const ARTIFACTS_DIRECTORY = `.${pkg.name}`;
|
|
15
|
+
|
|
14
16
|
export const INTERPOLATION_IDENTIFIER = '$FILES';
|
|
15
17
|
|
|
16
18
|
const PREFIX = '➡️ ';
|
package/src/lib/exec_staged.ts
CHANGED
|
@@ -19,3 +19,15 @@ export const execStaged = async (
|
|
|
19
19
|
return false;
|
|
20
20
|
}
|
|
21
21
|
};
|
|
22
|
+
|
|
23
|
+
export const recoverStaged = (cwd: string): boolean => {
|
|
24
|
+
const stage = new Stage(cwd);
|
|
25
|
+
|
|
26
|
+
try {
|
|
27
|
+
stage.recover();
|
|
28
|
+
return true;
|
|
29
|
+
} catch (error) {
|
|
30
|
+
console.log(`🪲 Log saved to: ${stage.logger.outFile}`);
|
|
31
|
+
return false;
|
|
32
|
+
}
|
|
33
|
+
};
|
package/src/lib/stage.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { ExecStagedConfig, StageOptions } from '../types.js';
|
|
2
2
|
import {
|
|
3
|
+
ARTIFACTS_DIRECTORY,
|
|
3
4
|
BACKUP_STASH_MESSAGE,
|
|
4
5
|
INTERPOLATION_IDENTIFIER,
|
|
5
6
|
MERGE_FILES,
|
|
@@ -19,12 +20,21 @@ export class Stage {
|
|
|
19
20
|
protected readonly cwd: string;
|
|
20
21
|
protected stashed: boolean = false;
|
|
21
22
|
private readonly status: { [file: string]: string } = {};
|
|
22
|
-
private readonly mergeStatus:
|
|
23
|
-
[file in (typeof MERGE_FILES)[number]]?: Buffer;
|
|
24
|
-
} = {};
|
|
23
|
+
private readonly mergeStatus: (typeof MERGE_FILES)[number][] = [];
|
|
25
24
|
private head?: string;
|
|
26
|
-
private
|
|
27
|
-
|
|
25
|
+
private _gitDir?: string;
|
|
26
|
+
|
|
27
|
+
private get gitDir(): string {
|
|
28
|
+
return (this._gitDir ??= this.git(['rev-parse', '--absolute-git-dir']));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
private get artifactsDir(): string {
|
|
32
|
+
return path.resolve(this.gitDir, ARTIFACTS_DIRECTORY);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
private get patchPath(): string {
|
|
36
|
+
return path.resolve(this.artifactsDir, 'patch.diff');
|
|
37
|
+
}
|
|
28
38
|
|
|
29
39
|
constructor(cwd: string, options: StageOptions = {}) {
|
|
30
40
|
this.cwd = cwd;
|
|
@@ -72,7 +82,7 @@ export class Stage {
|
|
|
72
82
|
)?.[1];
|
|
73
83
|
} catch (error) {
|
|
74
84
|
this.logger.log('⚠️ Git installation not found!');
|
|
75
|
-
throw
|
|
85
|
+
throw new Error('git installation not found');
|
|
76
86
|
}
|
|
77
87
|
|
|
78
88
|
if (!version || semver.lte(version, '2.13.0')) {
|
|
@@ -94,15 +104,50 @@ export class Stage {
|
|
|
94
104
|
throw new Error('cwd is not a git repository root directory');
|
|
95
105
|
}
|
|
96
106
|
|
|
107
|
+
// --absolute-git-dir resolves symlinks, --git-dir does not; if they
|
|
108
|
+
// differ, .git involves symlinks; walk the chain and reject if any
|
|
109
|
+
// target is inside the repo, because git stash --include-untracked
|
|
110
|
+
// would stash (and remove) it, breaking all subsequent operations
|
|
111
|
+
let gitDirPath = path.resolve(
|
|
112
|
+
this.cwd,
|
|
113
|
+
this.git(['rev-parse', '--git-dir']),
|
|
114
|
+
);
|
|
115
|
+
|
|
116
|
+
while (fs.lstatSync(gitDirPath).isSymbolicLink()) {
|
|
117
|
+
// readlink may return a relative path, so resolve against the
|
|
118
|
+
// symlink's parent directory to get an absolute path
|
|
119
|
+
const target = fs.readlinkSync(gitDirPath);
|
|
120
|
+
gitDirPath = path.resolve(path.dirname(gitDirPath), target);
|
|
121
|
+
|
|
122
|
+
if (!path.relative(this.cwd, gitDirPath).startsWith('..')) {
|
|
123
|
+
this.logger.log(
|
|
124
|
+
'⚠️ Git directory is a symlink pointing to a location within the repository!',
|
|
125
|
+
);
|
|
126
|
+
throw new Error(
|
|
127
|
+
'git directory is a symlink pointing to a location within the repository',
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (fs.existsSync(this.artifactsDir)) {
|
|
133
|
+
this.logger.log('⚠️ Found unexpected artifacts directory!');
|
|
134
|
+
this.logger.log(
|
|
135
|
+
'It must be left over from a previous failed run. Run `exec-staged recover` to restore your repository.',
|
|
136
|
+
);
|
|
137
|
+
throw new Error('unexpected artifacts directory');
|
|
138
|
+
}
|
|
139
|
+
|
|
97
140
|
if (this.indexOfBackupStash() !== -1) {
|
|
98
141
|
this.logger.log('⚠️ Found unexpected backup stash!');
|
|
99
142
|
this.logger.log(
|
|
100
|
-
'It must be left over from a previous failed run.
|
|
143
|
+
'It must be left over from a previous failed run. Run `exec-staged recover` to restore your repository.',
|
|
101
144
|
);
|
|
102
145
|
throw new Error('unexpected backup stash');
|
|
103
146
|
}
|
|
104
147
|
|
|
105
|
-
if (
|
|
148
|
+
if (
|
|
149
|
+
this.git(['log', '--grep', STAGED_CHANGES_COMMIT_MESSAGE, '--format=%s'])
|
|
150
|
+
) {
|
|
106
151
|
this.logger.log('⚠️ Found unexpected temporary commit!');
|
|
107
152
|
this.logger.log(
|
|
108
153
|
'It must be left over from a previous failed run. Remove it before proceeding.',
|
|
@@ -115,8 +160,6 @@ export class Stage {
|
|
|
115
160
|
this.logger.log(stageLifecycleMessages.prepare);
|
|
116
161
|
|
|
117
162
|
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
163
|
|
|
121
164
|
this.git(['status', '--porcelain', '--no-renames'])
|
|
122
165
|
.split('\n')
|
|
@@ -126,6 +169,8 @@ export class Stage {
|
|
|
126
169
|
// if there are no files in index or working tree, do not attempt to stash
|
|
127
170
|
if (Object.keys(this.status).length === 0) return;
|
|
128
171
|
|
|
172
|
+
fs.mkdirSync(this.artifactsDir);
|
|
173
|
+
|
|
129
174
|
try {
|
|
130
175
|
this.logger.debug('➡️ ➡️ Creating patch of unstaged changes...');
|
|
131
176
|
|
|
@@ -211,7 +256,11 @@ export class Stage {
|
|
|
211
256
|
.filter(([, s]) => s.match(new RegExp(`^[${diff}]`)))
|
|
212
257
|
.map(([f]) => f),
|
|
213
258
|
glob,
|
|
214
|
-
{
|
|
259
|
+
{
|
|
260
|
+
dot: true,
|
|
261
|
+
// match filenames in any directory when glob has no path separators
|
|
262
|
+
matchBase: !glob.includes('/'),
|
|
263
|
+
},
|
|
215
264
|
);
|
|
216
265
|
|
|
217
266
|
if (files.length === 0) {
|
|
@@ -269,16 +318,17 @@ export class Stage {
|
|
|
269
318
|
'--unidiff-zero',
|
|
270
319
|
'--whitespace=nowarn',
|
|
271
320
|
'--3way',
|
|
272
|
-
this.patchPath
|
|
321
|
+
this.patchPath,
|
|
273
322
|
]);
|
|
274
323
|
|
|
275
324
|
// unstaged deletions are not included in the patch and must be handled
|
|
276
325
|
// separately because the patch cannot be applied if such files are
|
|
277
|
-
// modified by tasks
|
|
326
|
+
// modified by tasks; use force: true to handle cases where the file
|
|
327
|
+
// doesn't exist (e.g., unstaged renames)
|
|
278
328
|
Object.entries(this.status)
|
|
279
329
|
.filter(([, s]) => s.match(/^.D/))
|
|
280
330
|
.map(([f]) => f)
|
|
281
|
-
.forEach((f) => fs.rmSync(path.resolve(this.cwd, f)));
|
|
331
|
+
.forEach((f) => fs.rmSync(path.resolve(this.cwd, f), { force: true }));
|
|
282
332
|
|
|
283
333
|
// make sure all restored unstaged changes are kept out of the index
|
|
284
334
|
this.git(['reset']);
|
|
@@ -287,14 +337,13 @@ export class Stage {
|
|
|
287
337
|
this.git(['reset', '--soft', this.head!]);
|
|
288
338
|
|
|
289
339
|
// clean up
|
|
290
|
-
|
|
340
|
+
this.restoreMergeStatus();
|
|
341
|
+
fs.rmSync(this.artifactsDir, { recursive: true });
|
|
291
342
|
this.git(['stash', 'drop', stash]);
|
|
292
343
|
} catch (error) {
|
|
293
344
|
this.logger.log('⚠️ Error restoring unstaged changes from stash!');
|
|
294
345
|
throw error;
|
|
295
346
|
}
|
|
296
|
-
|
|
297
|
-
this.restoreMergeStatus();
|
|
298
347
|
}
|
|
299
348
|
|
|
300
349
|
protected revert() {
|
|
@@ -325,6 +374,7 @@ export class Stage {
|
|
|
325
374
|
this.git(['stash', 'apply', '--index', stash!]);
|
|
326
375
|
this.git(['stash', 'drop', stash!]);
|
|
327
376
|
this.restoreMergeStatus();
|
|
377
|
+
fs.rmSync(this.artifactsDir, { recursive: true });
|
|
328
378
|
} catch (error) {
|
|
329
379
|
this.logger.log('⚠️ Failed to restore state from backup stash!');
|
|
330
380
|
throw error;
|
|
@@ -343,19 +393,20 @@ export class Stage {
|
|
|
343
393
|
|
|
344
394
|
private backupMergeStatus() {
|
|
345
395
|
for (const mergeFile of MERGE_FILES) {
|
|
346
|
-
const
|
|
347
|
-
if (fs.existsSync(
|
|
348
|
-
|
|
396
|
+
const src = path.resolve(this.gitDir, mergeFile);
|
|
397
|
+
if (fs.existsSync(src)) {
|
|
398
|
+
fs.copyFileSync(src, path.resolve(this.artifactsDir, mergeFile));
|
|
399
|
+
this.mergeStatus.push(mergeFile);
|
|
349
400
|
}
|
|
350
401
|
}
|
|
351
402
|
}
|
|
352
403
|
|
|
353
404
|
private restoreMergeStatus() {
|
|
354
|
-
for (const mergeFile of
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
405
|
+
for (const mergeFile of this.mergeStatus) {
|
|
406
|
+
fs.renameSync(
|
|
407
|
+
path.resolve(this.artifactsDir, mergeFile),
|
|
408
|
+
path.resolve(this.gitDir, mergeFile),
|
|
409
|
+
);
|
|
359
410
|
}
|
|
360
411
|
}
|
|
361
412
|
|
|
@@ -374,4 +425,41 @@ export class Stage {
|
|
|
374
425
|
|
|
375
426
|
return `stash@{${index}}`;
|
|
376
427
|
}
|
|
428
|
+
|
|
429
|
+
public recover() {
|
|
430
|
+
const stashIndex = this.indexOfBackupStash();
|
|
431
|
+
const stashFound = stashIndex !== -1;
|
|
432
|
+
const artifactsFound = fs.existsSync(this.artifactsDir);
|
|
433
|
+
|
|
434
|
+
if (!stashFound && !artifactsFound) {
|
|
435
|
+
this.logger.log('➡️ Nothing to recover.');
|
|
436
|
+
return;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
if (stashFound) {
|
|
440
|
+
const stash = `stash@{${stashIndex}}`;
|
|
441
|
+
|
|
442
|
+
this.logger.log('➡️ Found backup stash, restoring...');
|
|
443
|
+
|
|
444
|
+
const head = this.git(['rev-parse', `${stash}^1`]);
|
|
445
|
+
|
|
446
|
+
this.git(['add', '-A']);
|
|
447
|
+
this.git(['reset', '--hard', head]);
|
|
448
|
+
this.git(['stash', 'apply', '--index', stash]);
|
|
449
|
+
this.git(['stash', 'drop', stash]);
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
if (artifactsFound) {
|
|
453
|
+
this.logger.log('➡️ Found artifacts directory, cleaning up...');
|
|
454
|
+
|
|
455
|
+
for (const mergeFile of MERGE_FILES) {
|
|
456
|
+
const src = path.resolve(this.artifactsDir, mergeFile);
|
|
457
|
+
if (fs.existsSync(src)) {
|
|
458
|
+
fs.renameSync(src, path.resolve(this.gitDir, mergeFile));
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
fs.rmSync(this.artifactsDir, { recursive: true });
|
|
463
|
+
}
|
|
464
|
+
}
|
|
377
465
|
}
|