@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.
- package/README.md +53 -5
- package/dist/adapters/git/gitFacade.d.ts +8 -0
- package/dist/adapters/git/gitFacade.js +34 -1
- package/dist/adapters/git/gitProcessAdapter.js +1 -48
- package/dist/adapters/host/hostMessageWriterAdapter.d.ts +23 -0
- package/dist/adapters/host/hostMessageWriterAdapter.js +76 -0
- package/dist/adapters/process/processGroup.d.ts +18 -0
- package/dist/adapters/process/processGroup.js +52 -0
- package/dist/adapters/process/shellMessageCommandAdapter.d.ts +24 -0
- package/dist/adapters/process/shellMessageCommandAdapter.js +102 -0
- package/dist/adapters/watch/watcherFor.d.ts +22 -0
- package/dist/adapters/watch/watcherFor.js +29 -0
- package/dist/cli/commands/hookCommand.d.ts +2 -1
- package/dist/cli/commands/hookCommand.js +14 -8
- package/dist/cli/commands/runCommand.d.ts +10 -3
- package/dist/cli/commands/runCommand.js +45 -9
- package/dist/cli.js +2 -0
- package/dist/commit/commitLocalChangesController.d.ts +22 -1
- package/dist/commit/commitLocalChangesController.js +113 -4
- package/dist/contracts/ICommitMessageCommand.d.ts +32 -0
- package/dist/contracts/ICommitMessageCommand.js +1 -0
- package/dist/contracts/IGitConfigReader.d.ts +4 -1
- package/dist/contracts/IGitReader.d.ts +5 -0
- package/dist/contracts/IHostMessageWriter.d.ts +28 -0
- package/dist/contracts/IHostMessageWriter.js +1 -0
- package/dist/contracts/config.d.ts +34 -1
- package/dist/contracts/errors.d.ts +5 -1
- package/dist/contracts/errors.js +5 -1
- package/dist/contracts/library.d.ts +30 -0
- package/dist/contracts/logCodes.d.ts +1 -1
- package/dist/contracts/runtime.d.ts +4 -0
- package/dist/core/commit/commitMessage.d.ts +27 -0
- package/dist/core/commit/commitMessage.js +61 -0
- package/dist/core/config/parseConfig.d.ts +7 -4
- package/dist/core/config/parseConfig.js +172 -26
- package/dist/core/git/porcelain.d.ts +7 -0
- package/dist/core/git/porcelain.js +24 -0
- package/dist/enmetuInstances.d.ts +7 -4
- package/dist/enmetuInstances.js +52 -13
- package/dist/index.d.ts +1 -1
- package/dist/index.js +2 -0
- package/dist/library/enmetuLibrary.d.ts +2 -1
- package/dist/library/enmetuLibrary.js +5 -2
- package/dist/quarantine/pathQuarantine.d.ts +10 -0
- package/dist/quarantine/pathQuarantine.js +44 -2
- 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.
|
|
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.
|
|
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
|
|
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.
|