exec-staged 0.1.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/LICENSE ADDED
@@ -0,0 +1,19 @@
1
+ Copyright (c) 2025 Nick Barry
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining a copy
4
+ of this software and associated documentation files (the "Software"), to deal
5
+ in the Software without restriction, including without limitation the rights
6
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7
+ copies of the Software, and to permit persons to whom the Software is
8
+ furnished to do so, subject to the following conditions:
9
+
10
+ The above copyright notice and this permission notice shall be included in
11
+ all copies or substantial portions of the Software.
12
+
13
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
19
+ THE SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,177 @@
1
+ # Exec Staged
2
+
3
+ Run commands against files staged in git, ignoring unstaged changes and untracked files.
4
+
5
+ ### Use Cases
6
+
7
+ - 🧵 Lint new changes before commit.
8
+ - 🧪 Test changes in isolation before commit.
9
+ - ✨ ???
10
+
11
+ ### How it Works
12
+
13
+ 1. Unstaged changes and untracked files are hidden in a git stash. This inludes unstaged deletions, which are temporarily restored.
14
+ 2. User-configured tasks are run, with staged files passed in according to configuration.
15
+ 3. Any changes made by tasks are added to the git index. This includes additions and deletions.
16
+ 4. The stashed changes are restored.
17
+ 5. If any step fails, the initial state is fully restored.
18
+
19
+ ## Installation
20
+
21
+ Install from npm, using your preferred package manager:
22
+
23
+ ```bash
24
+ npm install --save-dev exec-staged
25
+ ```
26
+
27
+ ## Usage
28
+
29
+ ### Run from the CLI
30
+
31
+ If an `exec-staged` configuration file is present, it will be loaded by the executable:
32
+
33
+ ```bash
34
+ npx exec-staged
35
+ ```
36
+
37
+ If no configuration is present, the executable can still run tasks passed as arguments:
38
+
39
+ ```bash
40
+ npx exec-staged "npm test"
41
+ ```
42
+
43
+ ### Run in a Script
44
+
45
+ ```typescript
46
+ import { execStaged } from 'exec-staged';
47
+
48
+ const cwd = process.cwd();
49
+ const tasks = [`npm test`];
50
+ const options = { quiet: true };
51
+
52
+ const result = await execStaged(cwd, tasks, options);
53
+
54
+ if (!result) {
55
+ throw new Error('exec-staged task failed');
56
+ }
57
+ ```
58
+
59
+ ### Run in a Pre-Commit Hook
60
+
61
+ [Husky](https://github.com/typicode/husky) is recommended for handling pre-commit hooks.
62
+
63
+ Install Husky:
64
+
65
+ ```bash
66
+ npm install --save-dev husky
67
+ ```
68
+
69
+ Add a hook to `.husky/pre-commit`:
70
+
71
+ ```bash
72
+ #!/bin/sh
73
+ npx exec-staged
74
+ ```
75
+
76
+ Run husky:
77
+
78
+ ```bash
79
+ npx husky
80
+ ```
81
+
82
+ Add a `package.json` script to run husky whenever your repository is cloned:
83
+
84
+ ```json
85
+ {
86
+ "scripts": {
87
+ "prepare": "husky"
88
+ }
89
+ }
90
+ ```
91
+
92
+ `exec-staged` will do nothing unless configured. See configuration information below.
93
+
94
+ ## Configuration
95
+
96
+ `exec-staged` configuration consists of a list of commands to execute against the stage. Each command may be formatted as a plain `string`, or as an object containing additional attributes.
97
+
98
+ All [Cosmiconfig-compatible](https://www.npmjs.com/package/cosmiconfig#searchplaces) configuration files are supported.
99
+
100
+ Here is an example configuration:
101
+
102
+ ```typescript
103
+ // exec-staged.config.ts
104
+ import type { ExecStagedUserConfig } from 'exec-staged/types';
105
+
106
+ const config: ExecStagedUserConfig = [
107
+ 'knip',
108
+ 'knip --production',
109
+ { task: 'prettier --write $FILES', glob: '*.{js,ts,json,md}' },
110
+ ];
111
+
112
+ export default config;
113
+ ```
114
+
115
+ Plain commands are run every time, as-is:
116
+
117
+ <!-- prettier-ignore-start -->
118
+ ```typescript
119
+ 'knip --production'
120
+ ```
121
+ <!-- prettier-ignore-end -->
122
+
123
+ Commands which include the `$FILES` token are only run if staged files are found, and those files are interpolated into the command in place of the token.
124
+
125
+ <!-- prettier-ignore-start -->
126
+ ```typescript
127
+ 'prettier --write $FILES'
128
+ // => prettier --write new_file.js modified_file.js
129
+ ```
130
+ <!-- prettier-ignore-end -->
131
+
132
+ File filtering can be customized.
133
+
134
+ To filter files by name, add a `glob` filter (defaults to `'*'`):
135
+
136
+ ```typescript
137
+ { task: 'prettier --write $FILES', glob: '*.{js,ts,json,md}' }
138
+ ```
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)):
141
+
142
+ ```typescript
143
+ { task: 'prettier --write $FILES', diff: 'A' }
144
+ ```
145
+
146
+ Defining `diff` or `glob` on a task that does not include the `$FILES` token has no effect:
147
+
148
+ ```typescript
149
+ { task: 'knip', diff: 'NO EFFECT', glob: 'NO EFFECT' }
150
+ ```
151
+
152
+ ## Safety Features
153
+
154
+ Before running any potentially destructive scripts, `exec-staged` stores all outstanding changes, including untracked files, in a backup stash. If any task fails, or if `exec-staged` is interrupted by an end-process signal (such as via <kbd>Ctrl + C</kbd>), the repository's original state is restored using this stash. Avoid running any tasks that interact with git, especially those that make commits or modify the stash.
155
+
156
+ ### Recovery
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:
165
+
166
+ ```bash
167
+ git add -A
168
+ git reset --hard HEAD
169
+ git stash pop --index
170
+ ```
171
+
172
+ To prevent data loss, `exec-staged` will not run if a stash or commit from a previous run is present.
173
+
174
+ ## See Also
175
+
176
+ - [`lint-staged`](https://github.com/lint-staged/lint-staged): the inspiration for `exec-staged`.
177
+ - [`knip`](https://github.com/webpro-nl/knip): a linter that analyzes interactions between files, which is outside of the designed scope of `lint-staged`.
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
@@ -0,0 +1,19 @@
1
+ #!/usr/bin/env node
2
+ import pkg from '../../package.json' with { type: 'json' };
3
+ import { loadConfig } from '../lib/config.js';
4
+ import { execStaged } from '../lib/exec_staged.js';
5
+ import { program } from 'commander';
6
+ import path from 'node:path';
7
+ program.name(pkg.name).version(pkg.version).description(pkg.description);
8
+ program.option('--quiet', 'suppress output');
9
+ program.option('--cwd <cwd>', 'directory in which to run');
10
+ program.argument('[tasks...]');
11
+ 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
+ }
@@ -0,0 +1,2 @@
1
+ import { execStaged } from './lib/exec_staged.js';
2
+ export default execStaged;
package/dist/index.js ADDED
@@ -0,0 +1,2 @@
1
+ import { execStaged } from './lib/exec_staged.js';
2
+ export default execStaged;
@@ -0,0 +1,5 @@
1
+ import type { ExecStagedConfig, ExecStagedUserConfig } from '../types.js';
2
+ export declare const loadConfig: (cwd: string) => Promise<ExecStagedUserConfig>;
3
+ export declare const resolveConfig: (userConfig: ExecStagedUserConfig) => ExecStagedConfig;
4
+ /** @internal */
5
+ export declare const validateUserConfig: (userConfig: ExecStagedUserConfig) => void;
@@ -0,0 +1,47 @@
1
+ import pkg from '../../package.json' with { type: 'json' };
2
+ import { DEFAULT_CONFIG_ENTRY } from './constants.js';
3
+ import { cosmiconfig } from 'cosmiconfig';
4
+ export const loadConfig = async (cwd) => {
5
+ const configResult = await cosmiconfig(pkg.name).search(cwd);
6
+ if (configResult) {
7
+ const { config, filepath } = configResult;
8
+ console.log(`Config loaded from ${filepath}`);
9
+ validateUserConfig(config);
10
+ return config;
11
+ }
12
+ else {
13
+ console.log('No config found');
14
+ return [];
15
+ }
16
+ };
17
+ export const resolveConfig = (userConfig) => {
18
+ return userConfig.map((entry) => ({
19
+ ...DEFAULT_CONFIG_ENTRY,
20
+ ...(typeof entry === 'string' ? { task: entry } : entry),
21
+ }));
22
+ };
23
+ /** @internal */
24
+ export const validateUserConfig = (userConfig) => {
25
+ if (!isValidUserConfig(userConfig)) {
26
+ throw new Error('invalid config');
27
+ }
28
+ };
29
+ const isValidUserConfig = (userConfig) => {
30
+ return (Array.isArray(userConfig) &&
31
+ userConfig.every((userConfigEntry) => isValidUserConfigEntry(userConfigEntry)));
32
+ };
33
+ const isValidUserConfigEntry = (userConfigEntry) => {
34
+ if (typeof userConfigEntry === 'string')
35
+ return true;
36
+ if (typeof userConfigEntry !== 'object')
37
+ return false;
38
+ if (typeof userConfigEntry.task !== 'string')
39
+ return false;
40
+ if (typeof userConfigEntry.diff !== 'string' &&
41
+ typeof userConfigEntry.diff !== 'undefined')
42
+ return false;
43
+ if (typeof userConfigEntry.glob !== 'string' &&
44
+ typeof userConfigEntry.glob !== 'undefined')
45
+ return false;
46
+ return true;
47
+ };
@@ -0,0 +1,13 @@
1
+ import type { ExecStagedConfigEntry } from '../types.js';
2
+ export declare const DEFAULT_CONFIG_ENTRY: Omit<ExecStagedConfigEntry, 'task'>;
3
+ export declare const MERGE_FILES: readonly ["MERGE_HEAD", "MERGE_MODE", "MERGE_MSG"];
4
+ export declare const BACKUP_STASH_MESSAGE: string;
5
+ export declare const STAGED_CHANGES_COMMIT_MESSAGE: string;
6
+ export declare const INTERPOLATION_IDENTIFIER = "$FILES";
7
+ export declare const stageLifecycleMessages: {
8
+ check: string;
9
+ prepare: string;
10
+ run: string;
11
+ merge: string;
12
+ revert: string;
13
+ };
@@ -0,0 +1,17 @@
1
+ import pkg from '../../package.json' with { type: 'json' };
2
+ export const DEFAULT_CONFIG_ENTRY = {
3
+ glob: '*',
4
+ diff: 'ACMR',
5
+ };
6
+ export const MERGE_FILES = ['MERGE_HEAD', 'MERGE_MODE', 'MERGE_MSG'];
7
+ export const BACKUP_STASH_MESSAGE = `💾 ${pkg.name} backup stash`;
8
+ export const STAGED_CHANGES_COMMIT_MESSAGE = `💾 ${pkg.name} staged changes`;
9
+ export const INTERPOLATION_IDENTIFIER = '$FILES';
10
+ const PREFIX = '➡️ ';
11
+ export const stageLifecycleMessages = {
12
+ check: `${PREFIX}Checking environment...`,
13
+ prepare: `${PREFIX}Preparing repository...`,
14
+ run: `${PREFIX}Running tasks...`,
15
+ merge: `${PREFIX}Merging new changes with saved state...`,
16
+ revert: `${PREFIX}Reverting to saved state...`,
17
+ };
@@ -0,0 +1,2 @@
1
+ import type { ExecStagedUserConfig, StageOptions } from '../types.js';
2
+ export declare const execStaged: (cwd: string, tasks: ExecStagedUserConfig, options?: StageOptions) => Promise<boolean>;
@@ -0,0 +1,13 @@
1
+ import { resolveConfig } from './config.js';
2
+ import { Stage } from './stage.js';
3
+ export const execStaged = async (cwd, tasks, options = {}) => {
4
+ const stage = new Stage(cwd, options);
5
+ try {
6
+ await stage.exec(resolveConfig(tasks));
7
+ return true;
8
+ }
9
+ catch (error) {
10
+ console.log(`🪲 Log saved to: ${stage.logger.outFile}`);
11
+ return false;
12
+ }
13
+ };
@@ -0,0 +1,7 @@
1
+ export declare class Logger {
2
+ outFile: string;
3
+ private quiet;
4
+ constructor(quiet?: boolean);
5
+ log(...params: Parameters<typeof console.log>): void;
6
+ debug(...params: Parameters<typeof console.debug>): void;
7
+ }
@@ -0,0 +1,23 @@
1
+ import pkg from '../../package.json' with { type: 'json' };
2
+ import envPaths from 'env-paths';
3
+ import fs from 'node:fs';
4
+ import path from 'node:path';
5
+ export class Logger {
6
+ outFile;
7
+ quiet;
8
+ constructor(quiet = false) {
9
+ this.quiet = quiet;
10
+ this.outFile = path.resolve(envPaths(pkg.name).temp, `debug-${new Date().getTime().toString()}-${crypto.randomUUID()}.txt`);
11
+ fs.mkdirSync(path.dirname(this.outFile), { recursive: true });
12
+ this.debug(`${pkg.name} log: ${new Date().toLocaleString()}`);
13
+ }
14
+ log(...params) {
15
+ this.debug(...params);
16
+ if (!this.quiet) {
17
+ console.log(...params);
18
+ }
19
+ }
20
+ debug(...params) {
21
+ fs.appendFileSync(this.outFile, params.join('\n') + '\n');
22
+ }
23
+ }
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Spawn a child process asynchronously using the `execa` package.
3
+ * This is used to run `exec-staged` tasks. The `execa` package is
4
+ * required because it provides the `preferLocal` option.
5
+ *
6
+ * Child processes are configured to be killed if the main process is stopped.
7
+ *
8
+ * @param cwd Directory where command should be executed.
9
+ * @param args Command string or array of command tokens.
10
+ * @throws ExecaError
11
+ * @returns Execa `Result` promise.
12
+ */
13
+ export declare const spawn: (cwd: string, args: string[]) => Promise<import("execa").Result<{
14
+ cwd: string;
15
+ preferLocal: true;
16
+ stdout: ("pipe" | "inherit")[];
17
+ }>>;
18
+ /**
19
+ * Spawn a child process synchronously using the `execa` package.
20
+ * This is used to run `git` commands. A synchronous API is required because
21
+ * cleanup operations may be run via `on-process-exit`.
22
+ *
23
+ * @param cwd Directory where command should be executed.
24
+ * @param args Command string or array of command tokens.
25
+ * @throws ExecaSyncError
26
+ * @returns Execa `SyncResult`.
27
+ */
28
+ export declare const spawnSync: (cwd: string, args: string[]) => import("execa").SyncResult<{
29
+ cwd: string;
30
+ }>;
@@ -0,0 +1,37 @@
1
+ import { execa, execaSync } from 'execa';
2
+ import { registerExitHandler, deregisterExitHandler } from 'on-process-exit';
3
+ /**
4
+ * Spawn a child process asynchronously using the `execa` package.
5
+ * This is used to run `exec-staged` tasks. The `execa` package is
6
+ * required because it provides the `preferLocal` option.
7
+ *
8
+ * Child processes are configured to be killed if the main process is stopped.
9
+ *
10
+ * @param cwd Directory where command should be executed.
11
+ * @param args Command string or array of command tokens.
12
+ * @throws ExecaError
13
+ * @returns Execa `Result` promise.
14
+ */
15
+ export const spawn = async (cwd, args) => {
16
+ const subprocess = execa({
17
+ cwd,
18
+ preferLocal: true,
19
+ stdout: ['pipe', 'inherit'],
20
+ })(args[0], args.slice(1));
21
+ const id = registerExitHandler(() => subprocess.kill());
22
+ subprocess.once('close', () => deregisterExitHandler(id));
23
+ return subprocess;
24
+ };
25
+ /**
26
+ * Spawn a child process synchronously using the `execa` package.
27
+ * This is used to run `git` commands. A synchronous API is required because
28
+ * cleanup operations may be run via `on-process-exit`.
29
+ *
30
+ * @param cwd Directory where command should be executed.
31
+ * @param args Command string or array of command tokens.
32
+ * @throws ExecaSyncError
33
+ * @returns Execa `SyncResult`.
34
+ */
35
+ export const spawnSync = (cwd, args) => {
36
+ return execaSync({ cwd })(args[0], args.slice(1));
37
+ };
@@ -0,0 +1,24 @@
1
+ import type { ExecStagedConfig, StageOptions } from '../types.js';
2
+ import { Logger } from './logger.js';
3
+ export declare class Stage {
4
+ readonly logger: Logger;
5
+ protected readonly cwd: string;
6
+ protected stashed: boolean;
7
+ private readonly status;
8
+ private readonly mergeStatus;
9
+ private head?;
10
+ private patchPath?;
11
+ private gitDir?;
12
+ constructor(cwd: string, options?: StageOptions);
13
+ exec(tasks: ExecStagedConfig): Promise<void>;
14
+ protected check(): void;
15
+ protected prepare(): void;
16
+ protected run(tasks: ExecStagedConfig): Promise<void>;
17
+ protected merge(): void;
18
+ protected revert(): void;
19
+ protected git(args: string[]): string;
20
+ private backupMergeStatus;
21
+ private restoreMergeStatus;
22
+ private indexOfBackupStash;
23
+ private findBackupStash;
24
+ }