@fluado/enmetu 0.2.0 → 0.3.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.
Files changed (46) hide show
  1. package/README.md +53 -5
  2. package/dist/adapters/git/gitFacade.d.ts +8 -0
  3. package/dist/adapters/git/gitFacade.js +34 -1
  4. package/dist/adapters/git/gitProcessAdapter.js +1 -48
  5. package/dist/adapters/host/hostMessageWriterAdapter.d.ts +23 -0
  6. package/dist/adapters/host/hostMessageWriterAdapter.js +76 -0
  7. package/dist/adapters/process/processGroup.d.ts +18 -0
  8. package/dist/adapters/process/processGroup.js +52 -0
  9. package/dist/adapters/process/shellMessageCommandAdapter.d.ts +24 -0
  10. package/dist/adapters/process/shellMessageCommandAdapter.js +102 -0
  11. package/dist/adapters/watch/watcherFor.d.ts +22 -0
  12. package/dist/adapters/watch/watcherFor.js +29 -0
  13. package/dist/cli/commands/hookCommand.d.ts +2 -1
  14. package/dist/cli/commands/hookCommand.js +14 -8
  15. package/dist/cli/commands/runCommand.d.ts +10 -3
  16. package/dist/cli/commands/runCommand.js +45 -9
  17. package/dist/cli.js +2 -0
  18. package/dist/commit/commitLocalChangesController.d.ts +22 -1
  19. package/dist/commit/commitLocalChangesController.js +113 -4
  20. package/dist/contracts/ICommitMessageCommand.d.ts +32 -0
  21. package/dist/contracts/ICommitMessageCommand.js +1 -0
  22. package/dist/contracts/IGitConfigReader.d.ts +4 -1
  23. package/dist/contracts/IGitReader.d.ts +5 -0
  24. package/dist/contracts/IHostMessageWriter.d.ts +28 -0
  25. package/dist/contracts/IHostMessageWriter.js +1 -0
  26. package/dist/contracts/config.d.ts +34 -1
  27. package/dist/contracts/errors.d.ts +5 -1
  28. package/dist/contracts/errors.js +5 -1
  29. package/dist/contracts/library.d.ts +30 -0
  30. package/dist/contracts/logCodes.d.ts +1 -1
  31. package/dist/contracts/runtime.d.ts +4 -0
  32. package/dist/core/commit/commitMessage.d.ts +27 -0
  33. package/dist/core/commit/commitMessage.js +61 -0
  34. package/dist/core/config/parseConfig.d.ts +7 -4
  35. package/dist/core/config/parseConfig.js +172 -26
  36. package/dist/core/git/porcelain.d.ts +7 -0
  37. package/dist/core/git/porcelain.js +24 -0
  38. package/dist/enmetuInstances.d.ts +7 -4
  39. package/dist/enmetuInstances.js +52 -13
  40. package/dist/index.d.ts +1 -1
  41. package/dist/index.js +2 -0
  42. package/dist/library/enmetuLibrary.d.ts +2 -1
  43. package/dist/library/enmetuLibrary.js +5 -2
  44. package/dist/quarantine/pathQuarantine.d.ts +10 -0
  45. package/dist/quarantine/pathQuarantine.js +44 -2
  46. package/package.json +1 -1
package/README.md CHANGED
@@ -4,6 +4,8 @@
4
4
 
5
5
  <h1 align="center">enmetu</h1>
6
6
 
7
+ <p align="center"><sub>/enˈme.tu/, Esperanto for "commit!"</sub></p>
8
+
7
9
  Commits and syncs a git working tree automatically. Run it inside a repository: it commits after you stop editing, rebases onto the upstream, pushes, and pulls new commits.
8
10
 
9
11
  ## Why enmetu
@@ -20,7 +22,7 @@ Most auto-commit tools commit and push. enmetu also handles what goes wrong:
20
22
 
21
23
  ## Prerequisites
22
24
 
23
- - Node 24+ or Bun 1.3.6+
25
+ - Node 24+ or Bun 1.4.4+
24
26
  - git 2.38+
25
27
  - A checked-out branch with an upstream, at least one commit, and a committer identity
26
28
 
@@ -50,7 +52,7 @@ Keep `--bun`: without it, `bunx` runs enmetu with Node when Node is installed.
50
52
 
51
53
  ### Pinning
52
54
 
53
- Add the version for repeatable runs: `@fluado/enmetu@0.2.0`.
55
+ Add the version for repeatable runs: `@fluado/enmetu@0.3.0`.
54
56
 
55
57
  ## Commands
56
58
 
@@ -82,6 +84,21 @@ const { liveness, record } = await readEnmetuState('.')
82
84
  const exitCode = await enmetu.stop()
83
85
  ```
84
86
 
87
+ `commitMessage` lets the host program write each commit message:
88
+
89
+ ```js
90
+ startEnmetu({
91
+ repo: '.',
92
+ commitMessage: async ({ root, parent, tree, runCommand, signal }) =>
93
+ (await runCommand()) ?? 'Update notes',
94
+ })
95
+ ```
96
+
97
+ - `git diff-tree -r <parent> <tree>` in `root` lists the change.
98
+ - Return the message, its first line the subject, or `null` for `commitSubject`.
99
+ - `runCommand()` runs the person's `commitMessageCommand` on this change, at most once, and gives its message or `null`. With `commitMessage` set, the command runs only through this call.
100
+ - 10 seconds, then `signal` aborts and enmetu commits with `commitSubject`. So does a throw, or text without a subject; the log says `MESSAGE_WRITER_FAILED`.
101
+
85
102
  ## Configuration
86
103
 
87
104
  `enmetu.config.json` at the repository root, optional:
@@ -91,7 +108,7 @@ const exitCode = await enmetu.stop()
91
108
  | `quietMs` | `5000` | wait after the last change before committing |
92
109
  | `maxDelayMs` | `60000` | maximum delay while changes keep coming |
93
110
  | `pollMs` | `5000` | remote check interval |
94
- | `commitSubject` | `"enmetu: local changes"` | commit message |
111
+ | `commitSubject` | `"enmetu: local changes"` | commit message, and the fallback of `commitMessageCommand` |
95
112
  | `installHook` | `true` | install the pre-commit hook |
96
113
  | `skipJsonCheck` | `[]` | files exempt from the JSON check, such as `tsconfig.json` |
97
114
  | `gitTimeoutMs` | `120000` | timeout per git command |
@@ -102,6 +119,35 @@ const exitCode = await enmetu.stop()
102
119
 
103
120
  Unknown settings are logged and ignored.
104
121
 
122
+ ### Your own settings
123
+
124
+ `git config enmetu.<key>` overrides the file for you alone, with the same names and checks: `--global` for all your repositories, `--local` for one clone. Local beats global. `enmetu run` logs each override at start, such as `CONFIG_FROM_GIT quietMs 2000 (git config, local)`.
125
+
126
+ ```sh
127
+ git config --global enmetu.quietMs 2000
128
+ git config --local --add enmetu.skipJsonCheck tsconfig.json
129
+ ```
130
+
131
+ `skipJsonCheck` takes one path per entry; the list of the most specific scope replaces the file's.
132
+
133
+ ### Commit message command
134
+
135
+ A command that writes the commit message. Set it in git config only; enmetu ignores it in `enmetu.config.json`.
136
+
137
+ ```sh
138
+ git config --global enmetu.commitMessageCommand 'your-command'
139
+ ```
140
+
141
+ For each commit, enmetu runs it through the shell in the repository root:
142
+
143
+ - stdin: the patch, its first 64 KiB
144
+ - environment: `ENMETU_PARENT` and `ENMETU_TREE`, for your own `git diff "$ENMETU_PARENT" "$ENMETU_TREE"`
145
+ - stdout: the first line is the subject (at most 100 characters), the lines after it the body
146
+
147
+ After 5 seconds, or when the command fails or prints no usable subject, enmetu commits with `commitSubject` and logs `MESSAGE_COMMAND_FAILED`. The start line shows that a command is set, never the command.
148
+
149
+ Example: [`examples/commit-message`](https://codefloe.com/fluado/enmetu/src/branch/main/examples/commit-message), a Node or Bun script that names the changed document and adds a detail from a local model through [llm](https://llm.datasette.io), such as Apple's on-device model. Not part of the package.
150
+
105
151
  ## Status
106
152
 
107
153
  ```
@@ -145,6 +191,7 @@ stderr, one line per event:
145
191
  | `CONFLICT`, `UNPUSHED` | local commits not pushed |
146
192
  | `WAITING`, `PAUSED`, `OFFLINE` | sync on hold |
147
193
  | `HALTED` | see `enmetu status` |
194
+ | `MESSAGE_COMMAND_FAILED`, `MESSAGE_WRITER_FAILED` | message from the fallback named in the line |
148
195
  | `COMMIT_ORPHANED`, `STAGED_RESET`, `STAGED_REVERT`, `QUARANTINE_KEPT` | see Known limitations |
149
196
 
150
197
  ## State file
@@ -181,8 +228,9 @@ if command -v enmetu >/dev/null 2>&1; then enmetu hook pre-commit || exit 1; els
181
228
 
182
229
  - One repository per process. No system service, no notifications.
183
230
  - A partial staging pauses sync for up to `partialStagingWaitMs` (5 minutes by default), then enmetu commits everything. A case-only rename staged with `git mv` pauses it until you commit it.
184
- - `commit-msg` and `prepare-commit-msg` hooks don't run on enmetu's commits.
231
+ - `commit-msg` and `prepare-commit-msg` hooks don't run on enmetu's commits; `commitMessageCommand` writes the message instead.
185
232
  - Under Bun without file-watching permission, enmetu commits on its poll interval.
233
+ - Under Node on Linux, enmetu does not watch files: from Node 24.22, Node's recursive watch there can end the process ([nodejs/node#66791](https://github.com/nodejs/node/issues/66791)). enmetu commits on its poll interval (`pollMs`, 5 seconds by default) and says so once at start (`WATCHER_FAILED`). Bun watches.
186
234
  - A save during enmetu's pull can be overwritten by git without notice.
187
235
 
188
236
  Overlapping git commands:
@@ -194,7 +242,7 @@ Overlapping git commands:
194
242
  | `QUARANTINE_KEPT` | your edit collided with a version enmetu held aside | command in the log line |
195
243
  | `COMMIT_ORPHANED` | committed during enmetu's pull | `git cherry-pick -x` command in `enmetu status` |
196
244
  | `STAGED_REVERT` | a refused `git merge` restored old content | `git restore` command in the log line |
197
- | `could not write index` on a refused merge | git stashed your edits | `git fsck --no-reflogs --unreachable`, then `git stash apply <id>` |
245
+ | `could not write index` on a refused merge, or with older git (2.39) your edits gone after `would be overwritten by merge` | git stashed your edits | `git fsck --no-reflogs --unreachable`, then `git stash apply <id>` |
198
246
  | `git rebase` fails, no rebase in progress | started together with enmetu's | `git rebase --quit` if needed, `git reset --merge`, `git checkout <branch>`, retry |
199
247
 
200
248
  ## Contributing
@@ -1,3 +1,4 @@
1
+ import type { GitConfigEntry } from '../../contracts/config.js';
1
2
  import type { Divergence, FetchResult, HeadMove, PushResult, RebaseResult, RebaseStart, RemoteHead, RepositoryState, StagedFile, StageResult, StatusEntry, Upstream } from '../../contracts/gitTypes.js';
2
3
  import type { IFileSystem } from '../../contracts/IFileSystem.js';
3
4
  import type { CommitHookName, CommitTreeInput, HookRun, IGitCommitter } from '../../contracts/IGitCommitter.js';
@@ -79,6 +80,11 @@ export declare class GitFacade implements IGitReader, IGitWriter, IGitCommitter
79
80
  mergeBase(left: string, right: string): Promise<string | null>;
80
81
  /** git diff --name-only -z --no-renames <from> <to> (design 12.3). */
81
82
  changedPaths(from: string, to: string): Promise<readonly string[]>;
83
+ /**
84
+ * git diff-tree -r -p --no-color --no-ext-diff --no-textconv <from> <toTree>,
85
+ * stdout capped at maxBytes, the rest read and dropped (design 14.18).
86
+ */
87
+ patch(from: string, toTree: string, maxBytes: number): Promise<Buffer>;
82
88
  /**
83
89
  * git -c core.hooksPath=/dev/null read-tree --reset <commit> (design 12.7):
84
90
  * -m's one-tree merge, which keeps the stat data of matching entries, except
@@ -102,6 +108,8 @@ export declare class GitFacade implements IGitReader, IGitWriter, IGitCommitter
102
108
  */
103
109
  runHook(name: CommitHookName, args: readonly string[]): Promise<HookRun>;
104
110
  configValue(key: string): Promise<string | null>;
111
+ /** git config -z --show-scope --get-regexp ^enmetu\. ; exit 1 means none is set (design 14.18). */
112
+ enmetuEntries(): Promise<readonly GitConfigEntry[]>;
105
113
  stageAll(): Promise<StageResult>;
106
114
  unstage(paths: readonly string[]): Promise<void>;
107
115
  fetch(remote: string, remoteRef: string, intoRef: string): Promise<FetchResult>;
@@ -1,6 +1,6 @@
1
1
  import { join } from 'node:path';
2
2
  import { GitCommandError, GitOutputError } from '../../contracts/errors.js';
3
- import { hasRejectedRef, parseBatch, parseBatchCheck, parseDivergence, parseLsRemote, parseRawDiff, parseStatus, parseUpstream, } from '../../core/git/porcelain.js';
3
+ import { hasRejectedRef, parseBatch, parseBatchCheck, parseConfigEntries, parseDivergence, parseLsRemote, parseRawDiff, parseStatus, parseUpstream, } from '../../core/git/porcelain.js';
4
4
  /** At most this much blob content is requested from one `git cat-file --batch` call. */
5
5
  export const GIT_BATCH_CHUNK_BYTES = 67108864;
6
6
  /** A hook's stdout and stderr beyond this are dropped, never a failure (design 12.7, Decision 83). */
@@ -347,6 +347,27 @@ export class GitFacade {
347
347
  const args = ['diff', '--name-only', '-z', '--no-renames', from, to];
348
348
  return parseNulPaths((await this.gitOk(args)).stdout, 'git diff --name-only');
349
349
  }
350
+ /**
351
+ * git diff-tree -r -p --no-color --no-ext-diff --no-textconv <from> <toTree>,
352
+ * stdout capped at maxBytes, the rest read and dropped (design 14.18).
353
+ */
354
+ async patch(from, toTree, maxBytes) {
355
+ const args = [
356
+ 'diff-tree',
357
+ '-r',
358
+ '-p',
359
+ '--no-color',
360
+ '--no-ext-diff',
361
+ '--no-textconv',
362
+ from,
363
+ toTree,
364
+ ];
365
+ const result = await this.git(args, { outputCapBytes: maxBytes });
366
+ if (result.exitCode !== 0) {
367
+ throw gitFailed(args, result);
368
+ }
369
+ return result.stdout;
370
+ }
350
371
  /**
351
372
  * git -c core.hooksPath=/dev/null read-tree --reset <commit> (design 12.7):
352
373
  * -m's one-tree merge, which keeps the stat data of matching entries, except
@@ -448,6 +469,18 @@ export class GitFacade {
448
469
  }
449
470
  return stripOneTrailingNewline(result.stdout.toString('utf8'));
450
471
  }
472
+ /** git config -z --show-scope --get-regexp ^enmetu\. ; exit 1 means none is set (design 14.18). */
473
+ async enmetuEntries() {
474
+ const args = ['config', '-z', '--show-scope', '--get-regexp', '^enmetu\\.'];
475
+ const result = await this.git(args);
476
+ if (result.exitCode === 1) {
477
+ return [];
478
+ }
479
+ if (result.exitCode !== 0) {
480
+ throw gitFailed(args, result);
481
+ }
482
+ return parseConfigEntries(result.stdout);
483
+ }
451
484
  async stageAll() {
452
485
  const args = [...HOOKLESS, 'add', '-A', '--ignore-errors'];
453
486
  const result = await this.git(args);
@@ -1,6 +1,6 @@
1
1
  import { execFile, spawn } from 'node:child_process';
2
- import { kill } from 'node:process';
3
2
  import { GitCommandError } from '../../contracts/errors.js';
3
+ import { CappedOutput, errorCode, killGroup } from '../process/processGroup.js';
4
4
  /** 128 MiB: a cat-file batch chunk (64 MiB of content) plus headers fits with room. */
5
5
  export const GIT_MAX_BUFFER_BYTES = 134217728;
6
6
  const KILL_SIGNAL = 'SIGTERM';
@@ -184,53 +184,6 @@ export class GitProcessAdapter {
184
184
  return new GitCommandError(`${command} ended by signal ${error.signal ?? 'unknown'}`, 'GIT_FAILED', args, null, stderr);
185
185
  }
186
186
  }
187
- /** Keeps the first `cap` bytes of a stream and counts that more arrived. */
188
- class CappedOutput {
189
- cap;
190
- chunks = [];
191
- length = 0;
192
- dropped = false;
193
- constructor(cap) {
194
- this.cap = cap;
195
- }
196
- add(chunk) {
197
- const room = this.cap - this.length;
198
- if (room <= 0) {
199
- this.dropped = this.dropped || chunk.length > 0;
200
- return;
201
- }
202
- const kept = chunk.length > room ? chunk.subarray(0, room) : chunk;
203
- this.dropped = this.dropped || kept.length < chunk.length;
204
- this.chunks.push(kept);
205
- this.length += kept.length;
206
- }
207
- bytes() {
208
- return Buffer.concat(this.chunks);
209
- }
210
- text() {
211
- return this.bytes().toString('utf8');
212
- }
213
- }
214
- /** Signals git's whole process group; a group that is already gone is fine. */
215
- function killGroup(pid, signal) {
216
- if (pid === undefined) {
217
- return;
218
- }
219
- try {
220
- kill(-pid, signal);
221
- }
222
- catch (error) {
223
- if (errorCode(error) !== 'ESRCH') {
224
- throw error;
225
- }
226
- }
227
- }
228
- function errorCode(error) {
229
- if (typeof error === 'object' && error !== null && 'code' in error) {
230
- return typeof error.code === 'string' ? error.code : 'unknown';
231
- }
232
- return 'unknown';
233
- }
234
187
  /**
235
188
  * The adapter's environment, plus GIT_REFLOG_ACTION when the call names one.
236
189
  * The shared environment is never changed, so no other call inherits it.
@@ -0,0 +1,23 @@
1
+ import type { HostMessageInput, HostMessageRun, IHostMessageWriter } from '../../contracts/IHostMessageWriter.js';
2
+ import type { CommitMessageWriter } from '../../contracts/library.js';
3
+ export interface HostMessageWriterOptions {
4
+ /** The repository root, handed to the writer. */
5
+ readonly root: string;
6
+ readonly timeoutMs: number;
7
+ /** A stop request ends the wait at once (design 14.19). */
8
+ readonly abort?: AbortSignal | undefined;
9
+ }
10
+ /**
11
+ * Adapter: the host program's CommitMessageWriter with a time limit (design
12
+ * 14.19, Decision 135). The writer runs in enmetu's own process, so nothing
13
+ * can end it: at the limit or a stop request enmetu stops waiting, aborts the
14
+ * signal it handed over and commits with commitSubject, and a writer that
15
+ * ignores the signal finishes into nothing. Never throws; a writer's throw,
16
+ * synchronous or not, is a value.
17
+ */
18
+ export declare class HostMessageWriterAdapter implements IHostMessageWriter {
19
+ private readonly writer;
20
+ private readonly options;
21
+ constructor(writer: CommitMessageWriter, options: HostMessageWriterOptions);
22
+ write(input: HostMessageInput): Promise<HostMessageRun>;
23
+ }
@@ -0,0 +1,76 @@
1
+ import { errorMessage } from '../../core/errors/errorMessage.js';
2
+ /**
3
+ * Adapter: the host program's CommitMessageWriter with a time limit (design
4
+ * 14.19, Decision 135). The writer runs in enmetu's own process, so nothing
5
+ * can end it: at the limit or a stop request enmetu stops waiting, aborts the
6
+ * signal it handed over and commits with commitSubject, and a writer that
7
+ * ignores the signal finishes into nothing. Never throws; a writer's throw,
8
+ * synchronous or not, is a value.
9
+ */
10
+ export class HostMessageWriterAdapter {
11
+ writer;
12
+ options;
13
+ constructor(writer, options) {
14
+ this.writer = writer;
15
+ this.options = options;
16
+ }
17
+ write(input) {
18
+ const { abort, timeoutMs } = this.options;
19
+ if (abort?.aborted === true) {
20
+ return Promise.resolve(stopped());
21
+ }
22
+ const handover = new AbortController();
23
+ const deadline = AbortSignal.timeout(timeoutMs);
24
+ return new Promise((resolve) => {
25
+ let settled = false;
26
+ const settle = (run) => {
27
+ if (settled) {
28
+ return;
29
+ }
30
+ settled = true;
31
+ deadline.removeEventListener('abort', onDeadline);
32
+ abort?.removeEventListener('abort', onAbort);
33
+ resolve(run);
34
+ };
35
+ const end = (run) => {
36
+ handover.abort();
37
+ settle(run);
38
+ };
39
+ const onDeadline = () => end({ kind: 'failed', detail: `took longer than ${timeoutMs} ms` });
40
+ const onAbort = () => end(stopped());
41
+ deadline.addEventListener('abort', onDeadline, { once: true });
42
+ abort?.addEventListener('abort', onAbort, { once: true });
43
+ let pending;
44
+ try {
45
+ pending = Promise.resolve(this.writer({
46
+ root: this.options.root,
47
+ parent: input.parent,
48
+ tree: input.tree,
49
+ runCommand: input.runCommand,
50
+ signal: handover.signal,
51
+ }));
52
+ }
53
+ catch (error) {
54
+ settle(threw(error));
55
+ return;
56
+ }
57
+ pending.then((value) => settle(runOf(value)), (error) => settle(threw(error)));
58
+ });
59
+ }
60
+ }
61
+ /** A JavaScript host can return anything; only text and null mean something. */
62
+ function runOf(value) {
63
+ if (typeof value === 'string') {
64
+ return { kind: 'wrote', text: value };
65
+ }
66
+ if (value === null) {
67
+ return { kind: 'declined' };
68
+ }
69
+ return { kind: 'failed', detail: 'returned neither text nor null' };
70
+ }
71
+ function threw(error) {
72
+ return { kind: 'failed', detail: `threw: ${errorMessage(error)}` };
73
+ }
74
+ function stopped() {
75
+ return { kind: 'failed', detail: 'was ended because enmetu is stopping' };
76
+ }
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Plumbing shared by the two adapters that run a child in its own process
3
+ * group: git with a budget (a repository hook) and commitMessageCommand.
4
+ */
5
+ /** Keeps the first `cap` bytes of a stream and counts that more arrived. */
6
+ export declare class CappedOutput {
7
+ private readonly cap;
8
+ private readonly chunks;
9
+ private length;
10
+ dropped: boolean;
11
+ constructor(cap: number);
12
+ add(chunk: Buffer): void;
13
+ bytes(): Buffer;
14
+ text(): string;
15
+ }
16
+ /** Signals a child's whole process group; a group that is already gone is fine. */
17
+ export declare function killGroup(pid: number | undefined, signal: NodeJS.Signals): void;
18
+ export declare function errorCode(error: unknown): string;
@@ -0,0 +1,52 @@
1
+ import { kill } from 'node:process';
2
+ /**
3
+ * Plumbing shared by the two adapters that run a child in its own process
4
+ * group: git with a budget (a repository hook) and commitMessageCommand.
5
+ */
6
+ /** Keeps the first `cap` bytes of a stream and counts that more arrived. */
7
+ export class CappedOutput {
8
+ cap;
9
+ chunks = [];
10
+ length = 0;
11
+ dropped = false;
12
+ constructor(cap) {
13
+ this.cap = cap;
14
+ }
15
+ add(chunk) {
16
+ const room = this.cap - this.length;
17
+ if (room <= 0) {
18
+ this.dropped = this.dropped || chunk.length > 0;
19
+ return;
20
+ }
21
+ const kept = chunk.length > room ? chunk.subarray(0, room) : chunk;
22
+ this.dropped = this.dropped || kept.length < chunk.length;
23
+ this.chunks.push(kept);
24
+ this.length += kept.length;
25
+ }
26
+ bytes() {
27
+ return Buffer.concat(this.chunks);
28
+ }
29
+ text() {
30
+ return this.bytes().toString('utf8');
31
+ }
32
+ }
33
+ /** Signals a child's whole process group; a group that is already gone is fine. */
34
+ export function killGroup(pid, signal) {
35
+ if (pid === undefined) {
36
+ return;
37
+ }
38
+ try {
39
+ kill(-pid, signal);
40
+ }
41
+ catch (error) {
42
+ if (errorCode(error) !== 'ESRCH') {
43
+ throw error;
44
+ }
45
+ }
46
+ }
47
+ export function errorCode(error) {
48
+ if (typeof error === 'object' && error !== null && 'code' in error) {
49
+ return typeof error.code === 'string' ? error.code : 'unknown';
50
+ }
51
+ return 'unknown';
52
+ }
@@ -0,0 +1,24 @@
1
+ import type { CommitMessageCommandInput, CommitMessageCommandRun, ICommitMessageCommand } from '../../contracts/ICommitMessageCommand.js';
2
+ export interface ShellMessageCommandOptions {
3
+ /** The repository root; the command runs there. */
4
+ readonly cwd: string;
5
+ /** The environment the command starts from (Runtime.env); the adapter reads no global environment. */
6
+ readonly baseEnv: Readonly<Record<string, string | undefined>>;
7
+ readonly timeoutMs: number;
8
+ /** stdout beyond this is dropped, never a failure. */
9
+ readonly outputCapBytes: number;
10
+ /** A stop request ends a running command at once (design 14.18). */
11
+ readonly abort?: AbortSignal | undefined;
12
+ }
13
+ /**
14
+ * Adapter: commitMessageCommand through the shell (design 14.18, Decision
15
+ * 131). The command leads its own process group, so the time limit and a
16
+ * stop request end it and everything it started, with SIGKILL at once: a
17
+ * message writer holds nothing that needs a graceful end, and the commit does
18
+ * not wait for it. Never throws; every way the command can fail is a value.
19
+ */
20
+ export declare class ShellMessageCommandAdapter implements ICommitMessageCommand {
21
+ private readonly options;
22
+ constructor(options: ShellMessageCommandOptions);
23
+ run(input: CommitMessageCommandInput): Promise<CommitMessageCommandRun>;
24
+ }
@@ -0,0 +1,102 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { CappedOutput, errorCode, killGroup } from './processGroup.js';
3
+ /** Enough of stderr for the first line the failure names. */
4
+ const STDERR_CAP_BYTES = 4096;
5
+ /**
6
+ * Adapter: commitMessageCommand through the shell (design 14.18, Decision
7
+ * 131). The command leads its own process group, so the time limit and a
8
+ * stop request end it and everything it started, with SIGKILL at once: a
9
+ * message writer holds nothing that needs a graceful end, and the commit does
10
+ * not wait for it. Never throws; every way the command can fail is a value.
11
+ */
12
+ export class ShellMessageCommandAdapter {
13
+ options;
14
+ constructor(options) {
15
+ this.options = options;
16
+ }
17
+ run(input) {
18
+ const { abort, timeoutMs } = this.options;
19
+ if (abort?.aborted === true) {
20
+ return Promise.resolve(stopped());
21
+ }
22
+ return new Promise((resolve) => {
23
+ const child = spawn(input.command, {
24
+ cwd: this.options.cwd,
25
+ env: commandEnvironment(this.options.baseEnv, input),
26
+ shell: true,
27
+ detached: true,
28
+ stdio: ['pipe', 'pipe', 'pipe'],
29
+ windowsHide: true,
30
+ });
31
+ const stdout = new CappedOutput(this.options.outputCapBytes);
32
+ const stderr = new CappedOutput(STDERR_CAP_BYTES);
33
+ const deadline = AbortSignal.timeout(timeoutMs);
34
+ let settled = false;
35
+ const settle = (run) => {
36
+ if (settled) {
37
+ return;
38
+ }
39
+ settled = true;
40
+ deadline.removeEventListener('abort', onDeadline);
41
+ abort?.removeEventListener('abort', onAbort);
42
+ resolve(run);
43
+ };
44
+ const end = (run) => {
45
+ killGroup(child.pid, 'SIGKILL');
46
+ // A process that left the group (setsid) may still hold the pipes
47
+ // open; the run ends here regardless.
48
+ child.stdout.destroy();
49
+ child.stderr.destroy();
50
+ settle(run);
51
+ };
52
+ const onDeadline = () => end({ kind: 'failed', detail: `timed out after ${timeoutMs} ms` });
53
+ const onAbort = () => end(stopped());
54
+ deadline.addEventListener('abort', onDeadline, { once: true });
55
+ abort?.addEventListener('abort', onAbort, { once: true });
56
+ child.stdout.on('data', (chunk) => stdout.add(chunk));
57
+ child.stderr.on('data', (chunk) => stderr.add(chunk));
58
+ child.on('error', (error) => {
59
+ settle({
60
+ kind: 'failed',
61
+ detail: `could not be started in ${this.options.cwd} (${errorCode(error)})`,
62
+ });
63
+ });
64
+ child.on('close', (code, signal) => {
65
+ if (code === 0) {
66
+ settle({ kind: 'printed', stdout: stdout.text() });
67
+ return;
68
+ }
69
+ const ended = code === null
70
+ ? `ended by signal ${signal ?? 'unknown'}`
71
+ : `exited ${code}`;
72
+ const reason = firstLine(stderr.text());
73
+ settle({
74
+ kind: 'failed',
75
+ detail: reason === '' ? ended : `${ended}: ${reason}`,
76
+ });
77
+ });
78
+ // A command that ignores its input may exit before reading it; the
79
+ // resulting EPIPE must not become an uncaught exception.
80
+ child.stdin.on('error', () => undefined);
81
+ child.stdin.end(input.stdin);
82
+ });
83
+ }
84
+ }
85
+ function stopped() {
86
+ return { kind: 'failed', detail: 'was ended because enmetu is stopping' };
87
+ }
88
+ /** The base environment plus the commit's parent and tree, so a command can ask git itself. */
89
+ function commandEnvironment(baseEnv, input) {
90
+ const env = {};
91
+ for (const [key, value] of Object.entries(baseEnv)) {
92
+ if (value !== undefined) {
93
+ env[key] = value;
94
+ }
95
+ }
96
+ env.ENMETU_PARENT = input.parent;
97
+ env.ENMETU_TREE = input.tree;
98
+ return env;
99
+ }
100
+ function firstLine(text) {
101
+ return (text.trim().split('\n')[0] ?? '').trim();
102
+ }
@@ -0,0 +1,22 @@
1
+ import type { IWatcher, WatchListener } from '../../contracts/IWatcher.js';
2
+ import type { Runtime } from '../../contracts/runtime.js';
3
+ /** Why no file watch starts under Node on Linux (design 14.20, Decision 137). */
4
+ export declare const NODE_LINUX_WATCH_OFF = "file watching is off under Node on Linux, where its recursive watch can end the process (nodejs/node#66791)";
5
+ /**
6
+ * Null Object: a watcher that never starts and says so at once, so the
7
+ * daemon takes its existing path for a lost watch, one WATCHER_FAILED line,
8
+ * then the poll (Decision 21).
9
+ */
10
+ export declare class PollOnlyWatcher implements IWatcher {
11
+ private readonly reason;
12
+ constructor(reason: string);
13
+ start(listener: WatchListener): void;
14
+ close(): void;
15
+ }
16
+ /**
17
+ * The watcher for a runtime, chosen at the composition root (design 14.20):
18
+ * Node's recursive fs.watch on Linux throws ENOTDIR, EACCES or ELOOP inside
19
+ * its own event handler from 24.22.0 on, where no error listener sees it, so
20
+ * Node on Linux polls; Bun, and Node on every other platform, watch.
21
+ */
22
+ export declare function watcherFor(root: string, runtime: Pick<Runtime, 'platform' | 'engine'>): IWatcher;
@@ -0,0 +1,29 @@
1
+ import { FsWatchAdapter, nodeWatch } from './fsWatchAdapter.js';
2
+ /** Why no file watch starts under Node on Linux (design 14.20, Decision 137). */
3
+ export const NODE_LINUX_WATCH_OFF = 'file watching is off under Node on Linux, where its recursive watch can end the process (nodejs/node#66791)';
4
+ /**
5
+ * Null Object: a watcher that never starts and says so at once, so the
6
+ * daemon takes its existing path for a lost watch, one WATCHER_FAILED line,
7
+ * then the poll (Decision 21).
8
+ */
9
+ export class PollOnlyWatcher {
10
+ reason;
11
+ constructor(reason) {
12
+ this.reason = reason;
13
+ }
14
+ start(listener) {
15
+ listener.onFailure(this.reason);
16
+ }
17
+ close() { }
18
+ }
19
+ /**
20
+ * The watcher for a runtime, chosen at the composition root (design 14.20):
21
+ * Node's recursive fs.watch on Linux throws ENOTDIR, EACCES or ELOOP inside
22
+ * its own event handler from 24.22.0 on, where no error listener sees it, so
23
+ * Node on Linux polls; Bun, and Node on every other platform, watch.
24
+ */
25
+ export function watcherFor(root, runtime) {
26
+ return runtime.platform === 'linux' && runtime.engine === 'node'
27
+ ? new PollOnlyWatcher(NODE_LINUX_WATCH_OFF)
28
+ : new FsWatchAdapter(root, nodeWatch);
29
+ }
@@ -20,7 +20,8 @@ export interface HookCommandResult {
20
20
  /**
21
21
  * Controller:
22
22
  * 1. locator.locate(startDir); anything but found: notice, exit 0 (the hook fails open).
23
- * 2. parseConfig of <root>/enmetu.config.json; a ConfigError prints a notice and
23
+ * 2. parseConfig of <root>/enmetu.config.json with git config over it, as the
24
+ * daemon reads it (design 14.18); a ConfigError prints a notice and
24
25
  * applies the defaults, so a broken config never blocks the commit that fixes it.
25
26
  * Unknown keys are ignored without a word: the daemon names them at start (design 14.17).
26
27
  * 3. preCommitCheckController with the config's skipJsonCheck.