exec-staged 0.1.2 → 0.2.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/README.md +8 -14
- 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 +2 -1
- 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 +78 -18
- package/package.json +1 -1
- package/src/bin/cli.ts +27 -11
- package/src/index.ts +2 -1
- package/src/lib/constants.ts +3 -1
- package/src/lib/exec_staged.ts +12 -0
- package/src/lib/stage.ts +110 -22
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,13 +131,13 @@ 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}' }
|
|
138
138
|
```
|
|
139
139
|
|
|
140
|
-
To filter files by git status, add a `diff` filter (defaults to `'
|
|
140
|
+
To filter files by git status, add a `diff` filter (defaults to `'AM'`; see [here](https://git-scm.com/docs/git-status#_short_format)):
|
|
141
141
|
|
|
142
142
|
```typescript
|
|
143
143
|
{ task: 'prettier --write $FILES', diff: 'A' }
|
|
@@ -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
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import pkg from '../../package.json' with { type: 'json' };
|
|
2
2
|
export const DEFAULT_CONFIG_ENTRY = {
|
|
3
|
-
diff: '
|
|
3
|
+
diff: 'AM',
|
|
4
4
|
glob: '**',
|
|
5
5
|
};
|
|
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;
|
|
@@ -227,14 +258,14 @@ export class Stage {
|
|
|
227
258
|
// undo temporary commit while keeping its changes in the index
|
|
228
259
|
this.git(['reset', '--soft', this.head]);
|
|
229
260
|
// clean up
|
|
230
|
-
|
|
261
|
+
this.restoreMergeStatus();
|
|
262
|
+
fs.rmSync(this.artifactsDir, { recursive: true });
|
|
231
263
|
this.git(['stash', 'drop', stash]);
|
|
232
264
|
}
|
|
233
265
|
catch (error) {
|
|
234
266
|
this.logger.log('⚠️ Error restoring unstaged changes from stash!');
|
|
235
267
|
throw error;
|
|
236
268
|
}
|
|
237
|
-
this.restoreMergeStatus();
|
|
238
269
|
}
|
|
239
270
|
revert() {
|
|
240
271
|
this.logger.log(stageLifecycleMessages.revert);
|
|
@@ -259,6 +290,7 @@ export class Stage {
|
|
|
259
290
|
this.git(['stash', 'apply', '--index', stash]);
|
|
260
291
|
this.git(['stash', 'drop', stash]);
|
|
261
292
|
this.restoreMergeStatus();
|
|
293
|
+
fs.rmSync(this.artifactsDir, { recursive: true });
|
|
262
294
|
}
|
|
263
295
|
catch (error) {
|
|
264
296
|
this.logger.log('⚠️ Failed to restore state from backup stash!');
|
|
@@ -273,16 +305,16 @@ export class Stage {
|
|
|
273
305
|
}
|
|
274
306
|
backupMergeStatus() {
|
|
275
307
|
for (const mergeFile of MERGE_FILES) {
|
|
276
|
-
const
|
|
277
|
-
if (fs.existsSync(
|
|
278
|
-
|
|
308
|
+
const src = path.resolve(this.gitDir, mergeFile);
|
|
309
|
+
if (fs.existsSync(src)) {
|
|
310
|
+
fs.copyFileSync(src, path.resolve(this.artifactsDir, mergeFile));
|
|
311
|
+
this.mergeStatus.push(mergeFile);
|
|
279
312
|
}
|
|
280
313
|
}
|
|
281
314
|
}
|
|
282
315
|
restoreMergeStatus() {
|
|
283
|
-
for (const mergeFile of
|
|
284
|
-
|
|
285
|
-
fs.writeFileSync(path.resolve(this.gitDir, mergeFile), contents);
|
|
316
|
+
for (const mergeFile of this.mergeStatus) {
|
|
317
|
+
fs.renameSync(path.resolve(this.artifactsDir, mergeFile), path.resolve(this.gitDir, mergeFile));
|
|
286
318
|
}
|
|
287
319
|
}
|
|
288
320
|
indexOfBackupStash() {
|
|
@@ -297,4 +329,32 @@ export class Stage {
|
|
|
297
329
|
}
|
|
298
330
|
return `stash@{${index}}`;
|
|
299
331
|
}
|
|
332
|
+
recover() {
|
|
333
|
+
const stashIndex = this.indexOfBackupStash();
|
|
334
|
+
const stashFound = stashIndex !== -1;
|
|
335
|
+
const artifactsFound = fs.existsSync(this.artifactsDir);
|
|
336
|
+
if (!stashFound && !artifactsFound) {
|
|
337
|
+
this.logger.log('➡️ Nothing to recover.');
|
|
338
|
+
return;
|
|
339
|
+
}
|
|
340
|
+
if (stashFound) {
|
|
341
|
+
const stash = `stash@{${stashIndex}}`;
|
|
342
|
+
this.logger.log('➡️ Found backup stash, restoring...');
|
|
343
|
+
const head = this.git(['rev-parse', `${stash}^1`]);
|
|
344
|
+
this.git(['add', '-A']);
|
|
345
|
+
this.git(['reset', '--hard', head]);
|
|
346
|
+
this.git(['stash', 'apply', '--index', stash]);
|
|
347
|
+
this.git(['stash', 'drop', stash]);
|
|
348
|
+
}
|
|
349
|
+
if (artifactsFound) {
|
|
350
|
+
this.logger.log('➡️ Found artifacts directory, cleaning up...');
|
|
351
|
+
for (const mergeFile of MERGE_FILES) {
|
|
352
|
+
const src = path.resolve(this.artifactsDir, mergeFile);
|
|
353
|
+
if (fs.existsSync(src)) {
|
|
354
|
+
fs.renameSync(src, path.resolve(this.gitDir, mergeFile));
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
fs.rmSync(this.artifactsDir, { recursive: true });
|
|
358
|
+
}
|
|
359
|
+
}
|
|
300
360
|
}
|
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
|
@@ -2,7 +2,7 @@ import pkg from '../../package.json' with { type: 'json' };
|
|
|
2
2
|
import type { ExecStagedConfigEntry } from '../types.js';
|
|
3
3
|
|
|
4
4
|
export const DEFAULT_CONFIG_ENTRY: Omit<ExecStagedConfigEntry, 'task'> = {
|
|
5
|
-
diff: '
|
|
5
|
+
diff: 'AM',
|
|
6
6
|
glob: '**',
|
|
7
7
|
};
|
|
8
8
|
|
|
@@ -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,6 +256,11 @@ export class Stage {
|
|
|
211
256
|
.filter(([, s]) => s.match(new RegExp(`^[${diff}]`)))
|
|
212
257
|
.map(([f]) => f),
|
|
213
258
|
glob,
|
|
259
|
+
{
|
|
260
|
+
dot: true,
|
|
261
|
+
// match filenames in any directory when glob has no path separators
|
|
262
|
+
matchBase: !glob.includes('/'),
|
|
263
|
+
},
|
|
214
264
|
);
|
|
215
265
|
|
|
216
266
|
if (files.length === 0) {
|
|
@@ -268,7 +318,7 @@ export class Stage {
|
|
|
268
318
|
'--unidiff-zero',
|
|
269
319
|
'--whitespace=nowarn',
|
|
270
320
|
'--3way',
|
|
271
|
-
this.patchPath
|
|
321
|
+
this.patchPath,
|
|
272
322
|
]);
|
|
273
323
|
|
|
274
324
|
// unstaged deletions are not included in the patch and must be handled
|
|
@@ -286,14 +336,13 @@ export class Stage {
|
|
|
286
336
|
this.git(['reset', '--soft', this.head!]);
|
|
287
337
|
|
|
288
338
|
// clean up
|
|
289
|
-
|
|
339
|
+
this.restoreMergeStatus();
|
|
340
|
+
fs.rmSync(this.artifactsDir, { recursive: true });
|
|
290
341
|
this.git(['stash', 'drop', stash]);
|
|
291
342
|
} catch (error) {
|
|
292
343
|
this.logger.log('⚠️ Error restoring unstaged changes from stash!');
|
|
293
344
|
throw error;
|
|
294
345
|
}
|
|
295
|
-
|
|
296
|
-
this.restoreMergeStatus();
|
|
297
346
|
}
|
|
298
347
|
|
|
299
348
|
protected revert() {
|
|
@@ -324,6 +373,7 @@ export class Stage {
|
|
|
324
373
|
this.git(['stash', 'apply', '--index', stash!]);
|
|
325
374
|
this.git(['stash', 'drop', stash!]);
|
|
326
375
|
this.restoreMergeStatus();
|
|
376
|
+
fs.rmSync(this.artifactsDir, { recursive: true });
|
|
327
377
|
} catch (error) {
|
|
328
378
|
this.logger.log('⚠️ Failed to restore state from backup stash!');
|
|
329
379
|
throw error;
|
|
@@ -342,19 +392,20 @@ export class Stage {
|
|
|
342
392
|
|
|
343
393
|
private backupMergeStatus() {
|
|
344
394
|
for (const mergeFile of MERGE_FILES) {
|
|
345
|
-
const
|
|
346
|
-
if (fs.existsSync(
|
|
347
|
-
|
|
395
|
+
const src = path.resolve(this.gitDir, mergeFile);
|
|
396
|
+
if (fs.existsSync(src)) {
|
|
397
|
+
fs.copyFileSync(src, path.resolve(this.artifactsDir, mergeFile));
|
|
398
|
+
this.mergeStatus.push(mergeFile);
|
|
348
399
|
}
|
|
349
400
|
}
|
|
350
401
|
}
|
|
351
402
|
|
|
352
403
|
private restoreMergeStatus() {
|
|
353
|
-
for (const mergeFile of
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
404
|
+
for (const mergeFile of this.mergeStatus) {
|
|
405
|
+
fs.renameSync(
|
|
406
|
+
path.resolve(this.artifactsDir, mergeFile),
|
|
407
|
+
path.resolve(this.gitDir, mergeFile),
|
|
408
|
+
);
|
|
358
409
|
}
|
|
359
410
|
}
|
|
360
411
|
|
|
@@ -373,4 +424,41 @@ export class Stage {
|
|
|
373
424
|
|
|
374
425
|
return `stash@{${index}}`;
|
|
375
426
|
}
|
|
427
|
+
|
|
428
|
+
public recover() {
|
|
429
|
+
const stashIndex = this.indexOfBackupStash();
|
|
430
|
+
const stashFound = stashIndex !== -1;
|
|
431
|
+
const artifactsFound = fs.existsSync(this.artifactsDir);
|
|
432
|
+
|
|
433
|
+
if (!stashFound && !artifactsFound) {
|
|
434
|
+
this.logger.log('➡️ Nothing to recover.');
|
|
435
|
+
return;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
if (stashFound) {
|
|
439
|
+
const stash = `stash@{${stashIndex}}`;
|
|
440
|
+
|
|
441
|
+
this.logger.log('➡️ Found backup stash, restoring...');
|
|
442
|
+
|
|
443
|
+
const head = this.git(['rev-parse', `${stash}^1`]);
|
|
444
|
+
|
|
445
|
+
this.git(['add', '-A']);
|
|
446
|
+
this.git(['reset', '--hard', head]);
|
|
447
|
+
this.git(['stash', 'apply', '--index', stash]);
|
|
448
|
+
this.git(['stash', 'drop', stash]);
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
if (artifactsFound) {
|
|
452
|
+
this.logger.log('➡️ Found artifacts directory, cleaning up...');
|
|
453
|
+
|
|
454
|
+
for (const mergeFile of MERGE_FILES) {
|
|
455
|
+
const src = path.resolve(this.artifactsDir, mergeFile);
|
|
456
|
+
if (fs.existsSync(src)) {
|
|
457
|
+
fs.renameSync(src, path.resolve(this.gitDir, mergeFile));
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
fs.rmSync(this.artifactsDir, { recursive: true });
|
|
462
|
+
}
|
|
463
|
+
}
|
|
376
464
|
}
|