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 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,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 `'ACMR'`; see [here](https://git-scm.com/docs/git-status#_short_format)):
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`, 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;
@@ -1,11 +1,12 @@
1
1
  import pkg from '../../package.json' with { type: 'json' };
2
2
  export const DEFAULT_CONFIG_ENTRY = {
3
- diff: 'ACMR',
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 = {
@@ -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);
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.2",
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 };
@@ -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: 'ACMR',
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 = '➡️ ';
@@ -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,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
- fs.rmSync(this.patchPath!);
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 file = path.resolve(this.gitDir!, mergeFile);
346
- if (fs.existsSync(file)) {
347
- 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);
348
399
  }
349
400
  }
350
401
  }
351
402
 
352
403
  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);
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
  }