exec-staged 0.1.3 → 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 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 inludes unstaged deletions, which are temporarily restored.
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`, its backup stash should still be present.
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
- git add -A
168
- git reset --hard HEAD
169
- git stash pop --index
161
+ npx exec-staged recover
170
162
  ```
171
163
 
172
- To prevent data loss, `exec-staged` will not run if a stash or commit from a previous run is present.
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.argument('[tasks...]');
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
@@ -1,2 +1,3 @@
1
- import { execStaged } from './lib/exec_staged.js';
1
+ import { execStaged, recoverStaged } from './lib/exec_staged.js';
2
2
  export default execStaged;
3
+ export { recoverStaged };
package/dist/index.js CHANGED
@@ -1,2 +1,3 @@
1
- import { execStaged } from './lib/exec_staged.js';
1
+ import { execStaged, recoverStaged } from './lib/exec_staged.js';
2
2
  export default execStaged;
3
+ export { recoverStaged };
@@ -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;
@@ -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 = {
@@ -1,2 +1,3 @@
1
1
  import type { ExecStagedUserConfig, StageOptions } from '../types.js';
2
2
  export declare const execStaged: (cwd: string, tasks: ExecStagedUserConfig, options?: StageOptions) => Promise<boolean>;
3
+ export declare const recoverStaged: (cwd: string) => boolean;
@@ -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
+ };
@@ -7,8 +7,10 @@ export declare class Stage {
7
7
  private readonly status;
8
8
  private readonly mergeStatus;
9
9
  private head?;
10
- private patchPath?;
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
- patchPath;
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 error;
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. Remove it before proceeding.');
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']).includes(STAGED_CHANGES_COMMIT_MESSAGE)) {
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, { dot: true });
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
- fs.rmSync(this.patchPath);
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 file = path.resolve(this.gitDir, mergeFile);
277
- if (fs.existsSync(file)) {
278
- this.mergeStatus[mergeFile] = fs.readFileSync(file);
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 Object.keys(this.mergeStatus)) {
284
- const contents = this.mergeStatus[mergeFile];
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "exec-staged",
3
- "version": "0.1.3",
3
+ "version": "0.2.0",
4
4
  "description": "Run commands against the current git index",
5
5
  "keywords": [
6
6
  "git",
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.parse(process.argv);
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 options = program.opts();
16
- const args = program.args;
21
+ const result = await execStaged(cwd, tasks, options);
17
22
 
18
- const cwd = path.resolve(options.cwd ?? '');
23
+ if (!result) {
24
+ process.exitCode ||= 1;
25
+ }
26
+ });
19
27
 
20
- const tasks = args.length ? args : await loadConfig(cwd);
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 = await execStaged(cwd, tasks, options);
35
+ const result = recoverStaged(cwd);
23
36
 
24
- if (!result) {
25
- process.exitCode ||= 1;
26
- }
37
+ if (!result) {
38
+ process.exitCode ||= 1;
39
+ }
40
+ });
41
+
42
+ program.parse(process.argv);
package/src/index.ts CHANGED
@@ -1,3 +1,4 @@
1
- import { execStaged } from './lib/exec_staged.js';
1
+ import { execStaged, recoverStaged } from './lib/exec_staged.js';
2
2
 
3
3
  export default execStaged;
4
+ export { recoverStaged };
@@ -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 = '➡️ ';
@@ -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 patchPath?: string;
27
- private gitDir?: string;
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 error;
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. Remove it before proceeding.',
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 (this.git(['log']).includes(STAGED_CHANGES_COMMIT_MESSAGE)) {
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
- { dot: true },
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,7 +318,7 @@ 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
@@ -287,14 +336,13 @@ export class Stage {
287
336
  this.git(['reset', '--soft', this.head!]);
288
337
 
289
338
  // clean up
290
- fs.rmSync(this.patchPath!);
339
+ this.restoreMergeStatus();
340
+ fs.rmSync(this.artifactsDir, { recursive: true });
291
341
  this.git(['stash', 'drop', stash]);
292
342
  } catch (error) {
293
343
  this.logger.log('⚠️ Error restoring unstaged changes from stash!');
294
344
  throw error;
295
345
  }
296
-
297
- this.restoreMergeStatus();
298
346
  }
299
347
 
300
348
  protected revert() {
@@ -325,6 +373,7 @@ export class Stage {
325
373
  this.git(['stash', 'apply', '--index', stash!]);
326
374
  this.git(['stash', 'drop', stash!]);
327
375
  this.restoreMergeStatus();
376
+ fs.rmSync(this.artifactsDir, { recursive: true });
328
377
  } catch (error) {
329
378
  this.logger.log('⚠️ Failed to restore state from backup stash!');
330
379
  throw error;
@@ -343,19 +392,20 @@ export class Stage {
343
392
 
344
393
  private backupMergeStatus() {
345
394
  for (const mergeFile of MERGE_FILES) {
346
- const file = path.resolve(this.gitDir!, mergeFile);
347
- if (fs.existsSync(file)) {
348
- this.mergeStatus[mergeFile] = fs.readFileSync(file);
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);
349
399
  }
350
400
  }
351
401
  }
352
402
 
353
403
  private restoreMergeStatus() {
354
- for (const mergeFile of Object.keys(
355
- this.mergeStatus,
356
- ) as (keyof typeof this.mergeStatus)[]) {
357
- const contents = this.mergeStatus[mergeFile]!;
358
- fs.writeFileSync(path.resolve(this.gitDir!, mergeFile), contents);
404
+ for (const mergeFile of this.mergeStatus) {
405
+ fs.renameSync(
406
+ path.resolve(this.artifactsDir, mergeFile),
407
+ path.resolve(this.gitDir, mergeFile),
408
+ );
359
409
  }
360
410
  }
361
411
 
@@ -374,4 +424,41 @@ export class Stage {
374
424
 
375
425
  return `stash@{${index}}`;
376
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
+ }
377
464
  }