@runtimestudio/tailwind-sort-php 0.1.1 → 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/LICENSE CHANGED
@@ -1,6 +1,6 @@
1
1
  MIT License
2
2
 
3
- Copyright (c) 2026 Runtime Studio
3
+ Copyright (c) 2026 The Trustee for G&J Sevastos Family Trust t/a Runtime Studio
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
package/README.md CHANGED
@@ -1,7 +1,10 @@
1
- # @runtimestudio/tailwind-sort-php
1
+ # Tailwind CSS Class Sorter for PHP
2
2
 
3
- Tailwind CSS class sorting for **mixed PHP/HTML templates** — the WordPress template-partial case that
4
- `@prettier/plugin-php` + `prettier-plugin-tailwindcss` can't handle.
3
+ [![npm version](https://img.shields.io/npm/v/@runtimestudio/tailwind-sort-php)](https://www.npmjs.com/package/@runtimestudio/tailwind-sort-php)
4
+ [![License: MIT](https://img.shields.io/npm/l/@runtimestudio/tailwind-sort-php)](LICENSE)
5
+
6
+ `@runtimestudio/tailwind-sort-php` sorts Tailwind CSS classes in **plain PHP files, WordPress themes and plugins, and
7
+ mixed PHP/HTML templates** — the case `prettier-plugin-tailwindcss` can't parse and `@prettier/plugin-php` mangles.
5
8
 
6
9
  `prettier-plugin-tailwindcss` sorts classes beautifully, but it can't parse files that interleave PHP with HTML, and
7
10
  `@prettier/plugin-php` reformats the entire PHP file as a side effect. This tool sorts **only** the class attribute
@@ -63,6 +66,9 @@ bunx tailwind-sort-php --check
63
66
 
64
67
  # explicit stylesheet (overrides the Prettier config)
65
68
  bunx tailwind-sort-php --stylesheet ./resources/css/main.css
69
+
70
+ # one-time: install the pre-commit hook (see "Pre-commit gate" below)
71
+ bunx tailwind-sort-php init
66
72
  ```
67
73
 
68
74
  ### Options
@@ -76,6 +82,68 @@ bunx tailwind-sort-php --stylesheet ./resources/css/main.css
76
82
 
77
83
  Default globs are all `.php` files under the cwd; `node_modules`, `vendor`, `dist`, and `.git` are always skipped.
78
84
 
85
+ ## Editor integration
86
+
87
+ No IDE plugin is needed — two small setups cover the common workflows.
88
+
89
+ ### Sort on save (PhpStorm / IntelliJ)
90
+
91
+ Add a File Watcher (Settings → Tools → File Watchers → `+` → Custom):
92
+
93
+ - **File type:** PHP
94
+ - **Program:** `$ProjectFileDir$/node_modules/.bin/tailwind-sort-php`
95
+ - **Arguments:** `$FilePathRelativeToProjectRoot$`
96
+ - **Working directory:** `$ProjectFileDir$`
97
+
98
+ Untick "Auto-save edited files to trigger the watcher" so it runs on explicit save (~130 ms per file). The definition
99
+ lives in `.idea/watcherTasks.xml`, which you can commit to share it with your team.
100
+
101
+ ### Sort on save (VS Code)
102
+
103
+ Install the [Run on Save](https://marketplace.visualstudio.com/items?itemName=emeraldwalk.RunOnSave) extension, then add
104
+ to `.vscode/settings.json`:
105
+
106
+ ```json
107
+ {
108
+ "emeraldwalk.runonsave": {
109
+ "commands": [
110
+ {
111
+ "match": "\\.php$",
112
+ "cmd": "${workspaceFolder}/node_modules/.bin/tailwind-sort-php ${relativeFile}"
113
+ }
114
+ ]
115
+ }
116
+ }
117
+ ```
118
+
119
+ ### Pre-commit gate
120
+
121
+ Keep unsorted classes from landing regardless of the editor. One command installs a dependency-free Git hook at
122
+ `.githooks/pre-commit` and points `core.hooksPath` at it:
123
+
124
+ ```sh
125
+ # check-and-fail (default): names the unsorted files and blocks the commit
126
+ npx tailwind-sort-php init
127
+
128
+ # auto-fix: sorts the staged files in place, then blocks the commit for review and re-staging
129
+ npx tailwind-sort-php init --fix
130
+ ```
131
+
132
+ `init` is no-clobber by default: it refuses to overwrite a differing hook, repoint a `core.hooksPath` that's set
133
+ elsewhere (husky etc.), or disable hooks already living in `.git/hooks` — pass `--force` to override, `--dry-run` to
134
+ preview. Run it once per clone; commit the `.githooks/` directory to share the hook with your team.
135
+
136
+ Both variants check working-tree file contents, so with partial staging (`git add -p`) the hook can mis-report — and
137
+ under `--fix`, re-staging a fixed file can pull in unrelated unstaged hunks.
138
+
139
+ Wiring the gate into your own hook manager (husky, lefthook) instead? The staged-PHP check is this one-liner:
140
+
141
+ ```sh
142
+ git diff --cached --name-only -z --diff-filter=ACMR -- '*.php' | xargs -0 ./node_modules/.bin/tailwind-sort-php --check
143
+ ```
144
+
145
+ In CI there's no staged diff — just sweep the whole project with `npx tailwind-sort-php --check`.
146
+
79
147
  ## How it handles mixed templates
80
148
 
81
149
  PHP islands inside a class attribute are treated as opaque atoms that never move. Static text between islands is sorted
@@ -134,10 +202,11 @@ bun test # or: node --test "test/*.test.ts"
134
202
  bun run build # compile src → dist (tsc); the published artifact
135
203
  ```
136
204
 
137
- 46 tests: 41 core tests that are dependency-free (the sorter is injected, so they run against a mock `SortFn`), plus 5
205
+ 54 tests: 41 core tests that are dependency-free (the sorter is injected, so they run against a mock `SortFn`), 5
138
206
  integration tests that exercise the real `prettier-plugin-tailwindcss` sorter and skip automatically when the Tailwind
139
- toolchain isn't installed.
207
+ toolchain isn't installed, and 8 `init` tests that run against throwaway git repositories and skip when `git` is
208
+ unavailable.
140
209
 
141
210
  ## License
142
211
 
143
- MIT © Runtime Studio
212
+ [MIT](LICENSE) © Runtime Studio
package/dist/cli.d.ts CHANGED
@@ -4,6 +4,7 @@
4
4
  *
5
5
  * Usage:
6
6
  * tailwind-sort-php [options] [glob ...]
7
+ * tailwind-sort-php init [--fix] [--force] [--dry-run]
7
8
  *
8
9
  * Options:
9
10
  * --stylesheet <path> Tailwind v4 CSS entry
@@ -12,6 +13,6 @@
12
13
  * --no-short-tags Don't treat bare `<?` as a PHP open tag
13
14
  *
14
15
  * Defaults to all `.php` files under `cwd` (`"**" + "/*.php"`) when no globs are given.
15
- * Skips `node_modules`, `vendor`, `dist` and `.git`.
16
+ * Skips `node_modules`, `vendor`, `dist` and `.git`. The `init` subcommand installs the pre-commit hook; see `init.ts`.
16
17
  */
17
18
  export {};
package/dist/cli.js CHANGED
@@ -4,6 +4,7 @@
4
4
  *
5
5
  * Usage:
6
6
  * tailwind-sort-php [options] [glob ...]
7
+ * tailwind-sort-php init [--fix] [--force] [--dry-run]
7
8
  *
8
9
  * Options:
9
10
  * --stylesheet <path> Tailwind v4 CSS entry
@@ -12,11 +13,12 @@
12
13
  * --no-short-tags Don't treat bare `<?` as a PHP open tag
13
14
  *
14
15
  * Defaults to all `.php` files under `cwd` (`"**" + "/*.php"`) when no globs are given.
15
- * Skips `node_modules`, `vendor`, `dist` and `.git`.
16
+ * Skips `node_modules`, `vendor`, `dist` and `.git`. The `init` subcommand installs the pre-commit hook; see `init.ts`.
16
17
  */
17
18
  import { readFile, writeFile } from 'node:fs/promises';
18
19
  import { transform } from "./transform.js";
19
20
  import { createTailwindSortFn } from "./sorter.js";
21
+ import { runInit } from "./init.js";
20
22
  const IGNORE = ['node_modules', 'vendor', 'dist', '.git'];
21
23
  /**
22
24
  * Parse command-line arguments.
@@ -112,7 +114,10 @@ async function fromPrettierConfig() {
112
114
  }
113
115
  }
114
116
  async function main() {
115
- const cli = parseArgs(process.argv.slice(2));
117
+ const argv = process.argv.slice(2);
118
+ if (argv[0] === 'init')
119
+ return runInit(argv.slice(1));
120
+ const cli = parseArgs(argv);
116
121
  const pc = await fromPrettierConfig();
117
122
  const stylesheet = cli.stylesheet ?? pc.stylesheet;
118
123
  if (!stylesheet) {
package/dist/init.d.ts ADDED
@@ -0,0 +1,12 @@
1
+ /**
2
+ * tailwind-sort-php init
3
+ *
4
+ * Installs the pre-commit hook: writes `.githooks/pre-commit` and points `core.hooksPath` at it.
5
+ * No-clobber unless `--force`; `--fix` installs the auto-fixing variant; `--dry-run` previews.
6
+ */
7
+ /**
8
+ * Run the `init` subcommand: install the pre-commit hook and point `core.hooksPath` at it.
9
+ *
10
+ * @param argv Arguments after the `init` subcommand name.
11
+ */
12
+ export declare function runInit(argv: string[]): Promise<void>;
package/dist/init.js ADDED
@@ -0,0 +1,171 @@
1
+ /**
2
+ * tailwind-sort-php init
3
+ *
4
+ * Installs the pre-commit hook: writes `.githooks/pre-commit` and points `core.hooksPath` at it.
5
+ * No-clobber unless `--force`; `--fix` installs the auto-fixing variant; `--dry-run` previews.
6
+ */
7
+ import { execFileSync } from 'node:child_process';
8
+ import { chmod, mkdir, readdir, readFile, stat, writeFile } from 'node:fs/promises';
9
+ import { join } from 'node:path';
10
+ const HOOKS_DIR = '.githooks';
11
+ const HOOK_PATH = `${HOOKS_DIR}/pre-commit`;
12
+ /**
13
+ * Check-and-fail hook (default): names the unsorted files and blocks the commit; never writes.
14
+ */
15
+ const HOOK_CHECK = `#!/bin/sh
16
+ # Block commits with unsorted Tailwind classes in staged PHP files.
17
+ # Installed by \`tailwind-sort-php init\`. Note: checks working-tree file
18
+ # contents, so partial staging (git add -p) can mis-report; see README.
19
+ sorter=./node_modules/.bin/tailwind-sort-php
20
+ [ -x "$sorter" ] || exit 0
21
+ git diff --cached --name-only --diff-filter=ACMR -- '*.php' | grep -q . || exit 0
22
+ if git diff --cached --name-only -z --diff-filter=ACMR -- '*.php' | xargs -0 "$sorter" --check; then
23
+ exit 0
24
+ fi
25
+ echo >&2
26
+ echo "Unsorted Tailwind classes in staged PHP (see above)." >&2
27
+ echo "Fix with: npx tailwind-sort-php (or: bunx tailwind-sort-php), then re-stage." >&2
28
+ exit 1
29
+ `;
30
+ /**
31
+ * Auto-fix hook (--fix): sorts the staged files in place, then blocks the commit for review.
32
+ */
33
+ const HOOK_FIX = `#!/bin/sh
34
+ # Sort Tailwind classes in staged PHP files, then abort the commit so the
35
+ # changes can be reviewed and re-staged. Rewrites working-tree files.
36
+ # Installed by \`tailwind-sort-php init --fix\`. Note: with partial staging
37
+ # (git add -p), re-staging can pull in unrelated unstaged hunks; see README.
38
+ sorter=./node_modules/.bin/tailwind-sort-php
39
+ [ -x "$sorter" ] || exit 0
40
+ git diff --cached --name-only --diff-filter=ACMR -- '*.php' | grep -q . || exit 0
41
+ if git diff --cached --name-only -z --diff-filter=ACMR -- '*.php' | xargs -0 "$sorter" --check >/dev/null 2>&1; then
42
+ exit 0
43
+ fi
44
+ git diff --cached --name-only -z --diff-filter=ACMR -- '*.php' | xargs -0 "$sorter"
45
+ echo >&2
46
+ echo "Sorted Tailwind classes in staged PHP (see above)." >&2
47
+ echo "Review the changes, re-stage, and commit again." >&2
48
+ exit 1
49
+ `;
50
+ /**
51
+ * Parse `init` subcommand arguments.
52
+ *
53
+ * @param argv Arguments after the `init` subcommand name.
54
+ * @returns Parsed flags with defaults applied.
55
+ */
56
+ function parseArgs(argv) {
57
+ const cli = { fix: false, force: false, dryRun: false };
58
+ for (const a of argv) {
59
+ if (a === '--fix')
60
+ cli.fix = true;
61
+ else if (a === '--force')
62
+ cli.force = true;
63
+ else if (a === '--dry-run')
64
+ cli.dryRun = true;
65
+ else {
66
+ console.error(`Unknown init option: ${a}`);
67
+ process.exit(2);
68
+ }
69
+ }
70
+ return cli;
71
+ }
72
+ /**
73
+ * Run a git command and capture its output.
74
+ *
75
+ * @param args Arguments passed to `git`.
76
+ * @returns Trimmed stdout, or `null` when git exits non-zero (unset config, not a repo, …).
77
+ */
78
+ function git(args) {
79
+ try {
80
+ return execFileSync('git', args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
81
+ }
82
+ catch {
83
+ return null;
84
+ }
85
+ }
86
+ /**
87
+ * Print an error to stderr and exit with status 1.
88
+ *
89
+ * @param message Error text explaining why init refused to proceed.
90
+ */
91
+ function fail(message) {
92
+ console.error(message);
93
+ process.exit(1);
94
+ }
95
+ /**
96
+ * Run the `init` subcommand: install the pre-commit hook and point `core.hooksPath` at it.
97
+ *
98
+ * @param argv Arguments after the `init` subcommand name.
99
+ */
100
+ export async function runInit(argv) {
101
+ const cli = parseArgs(argv);
102
+ const hookBody = cli.fix ? HOOK_FIX : HOOK_CHECK;
103
+ const variant = cli.fix ? 'fix' : 'check';
104
+ // Anchor everything at the repository root: a relative `core.hooksPath` resolves there,
105
+ // and the hook's `./node_modules/...` path assumes hooks run from it (they do, for pre-commit).
106
+ const top = git(['rev-parse', '--show-toplevel']);
107
+ if (top === null)
108
+ fail('Not a git repository (or a bare one) — run init from inside a working tree.');
109
+ const gitDir = git(['rev-parse', '--absolute-git-dir']);
110
+ const hookAbs = join(top, HOOK_PATH);
111
+ // Decide whether `core.hooksPath` needs to change. Repointing it makes git ignore `.git/hooks` entirely,
112
+ // so refuse to silently disable hooks that already live there.
113
+ const hooksPath = git(['config', '--get', 'core.hooksPath']);
114
+ let setConfig = false;
115
+ if (hooksPath === null) {
116
+ const live = (await readdir(join(gitDir, 'hooks')).catch(() => [])).filter((f) => !f.endsWith('.sample'));
117
+ if (live.length > 0 && !cli.force) {
118
+ fail(`Found existing hook(s) in .git/hooks: ${live.join(', ')}.\n` +
119
+ `Setting core.hooksPath would disable them. Move them into ${HOOKS_DIR}/ first, ` +
120
+ 'or re-run with --force to proceed anyway.');
121
+ }
122
+ setConfig = true;
123
+ }
124
+ else if (hooksPath !== HOOKS_DIR) {
125
+ if (!cli.force) {
126
+ fail(`core.hooksPath is already set to "${hooksPath}" — add the hook there yourself, or re-run ` +
127
+ `with --force to repoint it to ${HOOKS_DIR}. Hook body:\n\n${hookBody}`);
128
+ }
129
+ setConfig = true;
130
+ }
131
+ // Decide whether the hook file needs writing; overwriting a differing hook needs --force.
132
+ const current = await readFile(hookAbs, 'utf8').catch(() => null);
133
+ let writeHook = current === null;
134
+ let repairMode = false;
135
+ if (current !== null && current !== hookBody) {
136
+ if (!cli.force) {
137
+ const installed = current === HOOK_CHECK ? 'the check variant' : current === HOOK_FIX ? 'the --fix variant' : 'a custom hook';
138
+ fail(`${HOOK_PATH} already exists and differs (${installed} is installed). Re-run with --force to overwrite.`);
139
+ }
140
+ writeHook = true;
141
+ }
142
+ if (current === hookBody) {
143
+ // Content is already right; still repair a missing executable bit, or git silently skips the hook.
144
+ repairMode = ((await stat(hookAbs)).mode & 0o111) === 0;
145
+ }
146
+ const done = [];
147
+ if (writeHook) {
148
+ if (!cli.dryRun) {
149
+ await mkdir(join(top, HOOKS_DIR), { recursive: true });
150
+ await writeFile(hookAbs, hookBody);
151
+ await chmod(hookAbs, 0o755);
152
+ }
153
+ done.push(cli.dryRun ? `would install ${HOOK_PATH} (${variant} variant)` : `installed ${HOOK_PATH} (${variant} variant)`);
154
+ }
155
+ if (repairMode) {
156
+ if (!cli.dryRun)
157
+ await chmod(hookAbs, 0o755);
158
+ done.push(cli.dryRun ? `would make ${HOOK_PATH} executable` : `made ${HOOK_PATH} executable`);
159
+ }
160
+ if (setConfig) {
161
+ if (!cli.dryRun)
162
+ git(['config', 'core.hooksPath', HOOKS_DIR]);
163
+ done.push(cli.dryRun ? `would set core.hooksPath = ${HOOKS_DIR}` : `set core.hooksPath = ${HOOKS_DIR}`);
164
+ }
165
+ if (done.length === 0) {
166
+ console.log(`Nothing to do — ${HOOK_PATH} (${variant} variant) is already installed.`);
167
+ return;
168
+ }
169
+ for (const line of done)
170
+ console.log(line);
171
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@runtimestudio/tailwind-sort-php",
3
- "version": "0.1.1",
4
- "description": "Tailwind CSS class sorting for mixed PHP/HTML templates (WordPress-friendly)",
3
+ "version": "0.2.0",
4
+ "description": "Tailwind CSS Class Sorter for PHP",
5
5
  "keywords": [
6
6
  "class-sorting",
7
7
  "formatter",
package/src/cli.ts CHANGED
@@ -4,6 +4,7 @@
4
4
  *
5
5
  * Usage:
6
6
  * tailwind-sort-php [options] [glob ...]
7
+ * tailwind-sort-php init [--fix] [--force] [--dry-run]
7
8
  *
8
9
  * Options:
9
10
  * --stylesheet <path> Tailwind v4 CSS entry
@@ -12,12 +13,13 @@
12
13
  * --no-short-tags Don't treat bare `<?` as a PHP open tag
13
14
  *
14
15
  * Defaults to all `.php` files under `cwd` (`"**" + "/*.php"`) when no globs are given.
15
- * Skips `node_modules`, `vendor`, `dist` and `.git`.
16
+ * Skips `node_modules`, `vendor`, `dist` and `.git`. The `init` subcommand installs the pre-commit hook; see `init.ts`.
16
17
  */
17
18
 
18
19
  import { readFile, writeFile } from 'node:fs/promises';
19
20
  import { transform, type TransformOptions } from './transform.ts';
20
21
  import { createTailwindSortFn } from './sorter.ts';
22
+ import { runInit } from './init.ts';
21
23
 
22
24
  const IGNORE = ['node_modules', 'vendor', 'dist', '.git'];
23
25
 
@@ -117,7 +119,10 @@ async function fromPrettierConfig(): Promise<{
117
119
  }
118
120
 
119
121
  async function main() {
120
- const cli = parseArgs(process.argv.slice(2));
122
+ const argv = process.argv.slice(2);
123
+ if (argv[0] === 'init') return runInit(argv.slice(1));
124
+
125
+ const cli = parseArgs(argv);
121
126
 
122
127
  const pc = await fromPrettierConfig();
123
128
  const stylesheet = cli.stylesheet ?? pc.stylesheet;
package/src/init.ts ADDED
@@ -0,0 +1,188 @@
1
+ /**
2
+ * tailwind-sort-php init
3
+ *
4
+ * Installs the pre-commit hook: writes `.githooks/pre-commit` and points `core.hooksPath` at it.
5
+ * No-clobber unless `--force`; `--fix` installs the auto-fixing variant; `--dry-run` previews.
6
+ */
7
+
8
+ import { execFileSync } from 'node:child_process';
9
+ import { chmod, mkdir, readdir, readFile, stat, writeFile } from 'node:fs/promises';
10
+ import { join } from 'node:path';
11
+
12
+ const HOOKS_DIR = '.githooks';
13
+ const HOOK_PATH = `${HOOKS_DIR}/pre-commit`;
14
+
15
+ /**
16
+ * Check-and-fail hook (default): names the unsorted files and blocks the commit; never writes.
17
+ */
18
+ const HOOK_CHECK = `#!/bin/sh
19
+ # Block commits with unsorted Tailwind classes in staged PHP files.
20
+ # Installed by \`tailwind-sort-php init\`. Note: checks working-tree file
21
+ # contents, so partial staging (git add -p) can mis-report; see README.
22
+ sorter=./node_modules/.bin/tailwind-sort-php
23
+ [ -x "$sorter" ] || exit 0
24
+ git diff --cached --name-only --diff-filter=ACMR -- '*.php' | grep -q . || exit 0
25
+ if git diff --cached --name-only -z --diff-filter=ACMR -- '*.php' | xargs -0 "$sorter" --check; then
26
+ exit 0
27
+ fi
28
+ echo >&2
29
+ echo "Unsorted Tailwind classes in staged PHP (see above)." >&2
30
+ echo "Fix with: npx tailwind-sort-php (or: bunx tailwind-sort-php), then re-stage." >&2
31
+ exit 1
32
+ `;
33
+
34
+ /**
35
+ * Auto-fix hook (--fix): sorts the staged files in place, then blocks the commit for review.
36
+ */
37
+ const HOOK_FIX = `#!/bin/sh
38
+ # Sort Tailwind classes in staged PHP files, then abort the commit so the
39
+ # changes can be reviewed and re-staged. Rewrites working-tree files.
40
+ # Installed by \`tailwind-sort-php init --fix\`. Note: with partial staging
41
+ # (git add -p), re-staging can pull in unrelated unstaged hunks; see README.
42
+ sorter=./node_modules/.bin/tailwind-sort-php
43
+ [ -x "$sorter" ] || exit 0
44
+ git diff --cached --name-only --diff-filter=ACMR -- '*.php' | grep -q . || exit 0
45
+ if git diff --cached --name-only -z --diff-filter=ACMR -- '*.php' | xargs -0 "$sorter" --check >/dev/null 2>&1; then
46
+ exit 0
47
+ fi
48
+ git diff --cached --name-only -z --diff-filter=ACMR -- '*.php' | xargs -0 "$sorter"
49
+ echo >&2
50
+ echo "Sorted Tailwind classes in staged PHP (see above)." >&2
51
+ echo "Review the changes, re-stage, and commit again." >&2
52
+ exit 1
53
+ `;
54
+
55
+ interface InitCli {
56
+ fix: boolean;
57
+ force: boolean;
58
+ dryRun: boolean;
59
+ }
60
+
61
+ /**
62
+ * Parse `init` subcommand arguments.
63
+ *
64
+ * @param argv Arguments after the `init` subcommand name.
65
+ * @returns Parsed flags with defaults applied.
66
+ */
67
+ function parseArgs(argv: string[]): InitCli {
68
+ const cli: InitCli = { fix: false, force: false, dryRun: false };
69
+ for (const a of argv) {
70
+ if (a === '--fix') cli.fix = true;
71
+ else if (a === '--force') cli.force = true;
72
+ else if (a === '--dry-run') cli.dryRun = true;
73
+ else {
74
+ console.error(`Unknown init option: ${a}`);
75
+ process.exit(2);
76
+ }
77
+ }
78
+ return cli;
79
+ }
80
+
81
+ /**
82
+ * Run a git command and capture its output.
83
+ *
84
+ * @param args Arguments passed to `git`.
85
+ * @returns Trimmed stdout, or `null` when git exits non-zero (unset config, not a repo, …).
86
+ */
87
+ function git(args: string[]): string | null {
88
+ try {
89
+ return execFileSync('git', args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
90
+ } catch {
91
+ return null;
92
+ }
93
+ }
94
+
95
+ /**
96
+ * Print an error to stderr and exit with status 1.
97
+ *
98
+ * @param message Error text explaining why init refused to proceed.
99
+ */
100
+ function fail(message: string): never {
101
+ console.error(message);
102
+ process.exit(1);
103
+ }
104
+
105
+ /**
106
+ * Run the `init` subcommand: install the pre-commit hook and point `core.hooksPath` at it.
107
+ *
108
+ * @param argv Arguments after the `init` subcommand name.
109
+ */
110
+ export async function runInit(argv: string[]): Promise<void> {
111
+ const cli = parseArgs(argv);
112
+ const hookBody = cli.fix ? HOOK_FIX : HOOK_CHECK;
113
+ const variant = cli.fix ? 'fix' : 'check';
114
+
115
+ // Anchor everything at the repository root: a relative `core.hooksPath` resolves there,
116
+ // and the hook's `./node_modules/...` path assumes hooks run from it (they do, for pre-commit).
117
+ const top = git(['rev-parse', '--show-toplevel']);
118
+ if (top === null) fail('Not a git repository (or a bare one) — run init from inside a working tree.');
119
+ const gitDir = git(['rev-parse', '--absolute-git-dir'])!;
120
+ const hookAbs = join(top, HOOK_PATH);
121
+
122
+ // Decide whether `core.hooksPath` needs to change. Repointing it makes git ignore `.git/hooks` entirely,
123
+ // so refuse to silently disable hooks that already live there.
124
+ const hooksPath = git(['config', '--get', 'core.hooksPath']);
125
+ let setConfig = false;
126
+ if (hooksPath === null) {
127
+ const live = (await readdir(join(gitDir, 'hooks')).catch(() => [])).filter((f) => !f.endsWith('.sample'));
128
+ if (live.length > 0 && !cli.force) {
129
+ fail(
130
+ `Found existing hook(s) in .git/hooks: ${live.join(', ')}.\n` +
131
+ `Setting core.hooksPath would disable them. Move them into ${HOOKS_DIR}/ first, ` +
132
+ 'or re-run with --force to proceed anyway.',
133
+ );
134
+ }
135
+ setConfig = true;
136
+ } else if (hooksPath !== HOOKS_DIR) {
137
+ if (!cli.force) {
138
+ fail(
139
+ `core.hooksPath is already set to "${hooksPath}" — add the hook there yourself, or re-run ` +
140
+ `with --force to repoint it to ${HOOKS_DIR}. Hook body:\n\n${hookBody}`,
141
+ );
142
+ }
143
+ setConfig = true;
144
+ }
145
+
146
+ // Decide whether the hook file needs writing; overwriting a differing hook needs --force.
147
+ const current = await readFile(hookAbs, 'utf8').catch(() => null);
148
+ let writeHook = current === null;
149
+ let repairMode = false;
150
+ if (current !== null && current !== hookBody) {
151
+ if (!cli.force) {
152
+ const installed =
153
+ current === HOOK_CHECK ? 'the check variant' : current === HOOK_FIX ? 'the --fix variant' : 'a custom hook';
154
+ fail(`${HOOK_PATH} already exists and differs (${installed} is installed). Re-run with --force to overwrite.`);
155
+ }
156
+ writeHook = true;
157
+ }
158
+ if (current === hookBody) {
159
+ // Content is already right; still repair a missing executable bit, or git silently skips the hook.
160
+ repairMode = ((await stat(hookAbs)).mode & 0o111) === 0;
161
+ }
162
+
163
+ const done: string[] = [];
164
+ if (writeHook) {
165
+ if (!cli.dryRun) {
166
+ await mkdir(join(top, HOOKS_DIR), { recursive: true });
167
+ await writeFile(hookAbs, hookBody);
168
+ await chmod(hookAbs, 0o755);
169
+ }
170
+ done.push(
171
+ cli.dryRun ? `would install ${HOOK_PATH} (${variant} variant)` : `installed ${HOOK_PATH} (${variant} variant)`,
172
+ );
173
+ }
174
+ if (repairMode) {
175
+ if (!cli.dryRun) await chmod(hookAbs, 0o755);
176
+ done.push(cli.dryRun ? `would make ${HOOK_PATH} executable` : `made ${HOOK_PATH} executable`);
177
+ }
178
+ if (setConfig) {
179
+ if (!cli.dryRun) git(['config', 'core.hooksPath', HOOKS_DIR]);
180
+ done.push(cli.dryRun ? `would set core.hooksPath = ${HOOKS_DIR}` : `set core.hooksPath = ${HOOKS_DIR}`);
181
+ }
182
+
183
+ if (done.length === 0) {
184
+ console.log(`Nothing to do — ${HOOK_PATH} (${variant} variant) is already installed.`);
185
+ return;
186
+ }
187
+ for (const line of done) console.log(line);
188
+ }