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 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;
@@ -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
- fs.rmSync(this.patchPath);
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 file = path.resolve(this.gitDir, mergeFile);
277
- if (fs.existsSync(file)) {
278
- this.mergeStatus[mergeFile] = fs.readFileSync(file);
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 Object.keys(this.mergeStatus)) {
284
- const contents = this.mergeStatus[mergeFile];
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "exec-staged",
3
- "version": "0.1.3",
3
+ "version": "0.2.1",
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,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
- fs.rmSync(this.patchPath!);
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 file = path.resolve(this.gitDir!, mergeFile);
347
- if (fs.existsSync(file)) {
348
- this.mergeStatus[mergeFile] = fs.readFileSync(file);
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 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);
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
  }