@fluado/enmetu 0.1.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 +99 -10
- package/dist/adapters/git/gitFacade.d.ts +8 -0
- package/dist/adapters/git/gitFacade.js +34 -1
- package/dist/adapters/git/gitLocatorFacade.js +6 -3
- 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 +3 -1
- package/dist/cli/commands/hookCommand.js +15 -7
- package/dist/cli/commands/runCommand.d.ts +10 -2
- package/dist/cli/commands/runCommand.js +51 -5
- package/dist/cli/exitCodes.js +0 -1
- package/dist/cli.js +2 -0
- package/dist/commit/commitLocalChangesController.d.ts +28 -7
- package/dist/commit/commitLocalChangesController.js +135 -27
- 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/IRepositoryLocator.d.ts +5 -1
- package/dist/contracts/config.d.ts +44 -5
- package/dist/contracts/errors.d.ts +7 -3
- 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/contracts/syncTypes.d.ts +3 -3
- package/dist/contracts/syncTypes.js +1 -1
- package/dist/core/commit/commitMemory.js +1 -1
- package/dist/core/commit/commitMessage.d.ts +27 -0
- package/dist/core/commit/commitMessage.js +61 -0
- package/dist/core/config/parseConfig.d.ts +9 -3
- package/dist/core/config/parseConfig.js +186 -60
- package/dist/core/git/porcelain.d.ts +9 -1
- package/dist/core/git/porcelain.js +27 -3
- package/dist/core/status/statusHeadline.js +1 -1
- package/dist/enmetuInstances.d.ts +7 -4
- package/dist/enmetuInstances.js +53 -15
- package/dist/hooks/preCommitHookInstaller.js +10 -0
- 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/dist/sync/syncCycleController.d.ts +1 -1
- package/dist/sync/syncCycleController.js +2 -2
- package/dist/sync/syncCycleRunner.js +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,24 +1,58 @@
|
|
|
1
|
-
|
|
1
|
+
<p align="center">
|
|
2
|
+
<img src="logo.svg" width="80" height="80" alt="enmetu">
|
|
3
|
+
</p>
|
|
4
|
+
|
|
5
|
+
<h1 align="center">enmetu</h1>
|
|
6
|
+
|
|
7
|
+
<p align="center"><sub>/enˈme.tu/, Esperanto for "commit!"</sub></p>
|
|
2
8
|
|
|
3
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.
|
|
4
10
|
|
|
11
|
+
## Why enmetu
|
|
12
|
+
|
|
13
|
+
Most auto-commit tools commit and push. enmetu also handles what goes wrong:
|
|
14
|
+
|
|
15
|
+
- **Conflicts.** A pull that conflicts is undone and held. Your commits stay local, and enmetu retries when either side changes. It never commits conflict markers and never force-pushes.
|
|
16
|
+
- **No stash.** A file it won't commit (conflict markers, invalid JSON) stays on disk and doesn't block the rest of the sync. Its pre-commit hook applies the same check to your own commits.
|
|
17
|
+
- **Plain git.** You and your agents keep using git. enmetu commits through its own index, waits up to 5 minutes on a partial staging, and pauses during your merge or rebase.
|
|
18
|
+
- **Halts.** A rewritten upstream, or a state it cannot verify, halts it. The halt survives restarts until you run `enmetu resume`.
|
|
19
|
+
- **Status.** `enmetu status` says whether your work is committed and pushed, and during a conflict prints the commands to end it.
|
|
20
|
+
- **Dependencies.** None besides git. Runs under Node or Bun, as a command or as a library.
|
|
21
|
+
- **Tests.** Real git against [28 conflict shapes](https://codefloe.com/fluado/enmetu/src/branch/main/src/__tests__/conflictMatrix.scenario.integration.test.ts) (renames against deletions, binary files, case-only renames, line endings, a merge in progress), [several clones converging on one history](https://codefloe.com/fluado/enmetu/src/branch/main/src/__tests__/convergence.scenario.integration.test.ts), and [a stress test](https://codefloe.com/fluado/enmetu/src/branch/main/src/__tests__/concurrencyStress.scenario.integration.test.ts) where a scripted person runs git next to enmetu as fast as it can, with every write checked against the final history.
|
|
22
|
+
|
|
5
23
|
## Prerequisites
|
|
6
24
|
|
|
7
|
-
- Node 24+ or Bun 1.
|
|
25
|
+
- Node 24+ or Bun 1.4.4+
|
|
8
26
|
- git 2.38+
|
|
9
27
|
- A checked-out branch with an upstream, at least one commit, and a committer identity
|
|
10
28
|
|
|
11
29
|
## Install
|
|
12
30
|
|
|
31
|
+
### Node
|
|
32
|
+
|
|
13
33
|
Inside a repository:
|
|
14
34
|
|
|
15
35
|
```sh
|
|
16
36
|
npx @fluado/enmetu run
|
|
17
37
|
```
|
|
18
38
|
|
|
19
|
-
|
|
39
|
+
As a project dependency: `npm install -D @fluado/enmetu`, then `npx enmetu run`.
|
|
40
|
+
|
|
41
|
+
### Bun
|
|
42
|
+
|
|
43
|
+
Inside a repository:
|
|
44
|
+
|
|
45
|
+
```sh
|
|
46
|
+
bunx --bun @fluado/enmetu run
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
As a project dependency: `bun add -d @fluado/enmetu`, then `bunx --bun enmetu run`.
|
|
50
|
+
|
|
51
|
+
Keep `--bun`: without it, `bunx` runs enmetu with Node when Node is installed.
|
|
52
|
+
|
|
53
|
+
### Pinning
|
|
20
54
|
|
|
21
|
-
|
|
55
|
+
Add the version for repeatable runs: `@fluado/enmetu@0.3.0`.
|
|
22
56
|
|
|
23
57
|
## Commands
|
|
24
58
|
|
|
@@ -50,6 +84,21 @@ const { liveness, record } = await readEnmetuState('.')
|
|
|
50
84
|
const exitCode = await enmetu.stop()
|
|
51
85
|
```
|
|
52
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
|
+
|
|
53
102
|
## Configuration
|
|
54
103
|
|
|
55
104
|
`enmetu.config.json` at the repository root, optional:
|
|
@@ -59,16 +108,46 @@ const exitCode = await enmetu.stop()
|
|
|
59
108
|
| `quietMs` | `5000` | wait after the last change before committing |
|
|
60
109
|
| `maxDelayMs` | `60000` | maximum delay while changes keep coming |
|
|
61
110
|
| `pollMs` | `5000` | remote check interval |
|
|
62
|
-
| `commitSubject` | `"enmetu: local changes"` | commit message |
|
|
111
|
+
| `commitSubject` | `"enmetu: local changes"` | commit message, and the fallback of `commitMessageCommand` |
|
|
63
112
|
| `installHook` | `true` | install the pre-commit hook |
|
|
64
113
|
| `skipJsonCheck` | `[]` | files exempt from the JSON check, such as `tsconfig.json` |
|
|
65
114
|
| `gitTimeoutMs` | `120000` | timeout per git command |
|
|
66
115
|
| `stateHistory` | `20` | state changes kept in the state file (0 to 100) |
|
|
67
|
-
| `
|
|
68
|
-
| `stagedSelectionMaxWaitMs` | `null` | with `"wait"`: commit everything after this long |
|
|
116
|
+
| `partialStagingWaitMs` | `300000` | with a partial staging: commit everything after this long; `0` at once |
|
|
69
117
|
| `commitHooks` | `true` | run your commit hooks on enmetu's commits |
|
|
70
118
|
| `hookTimeoutMs` | `600000` | timeout per hook |
|
|
71
119
|
|
|
120
|
+
Unknown settings are logged and ignored.
|
|
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
|
+
|
|
72
151
|
## Status
|
|
73
152
|
|
|
74
153
|
```
|
|
@@ -112,6 +191,7 @@ stderr, one line per event:
|
|
|
112
191
|
| `CONFLICT`, `UNPUSHED` | local commits not pushed |
|
|
113
192
|
| `WAITING`, `PAUSED`, `OFFLINE` | sync on hold |
|
|
114
193
|
| `HALTED` | see `enmetu status` |
|
|
194
|
+
| `MESSAGE_COMMAND_FAILED`, `MESSAGE_WRITER_FAILED` | message from the fallback named in the line |
|
|
115
195
|
| `COMMIT_ORPHANED`, `STAGED_RESET`, `STAGED_REVERT`, `QUARANTINE_KEPT` | see Known limitations |
|
|
116
196
|
|
|
117
197
|
## State file
|
|
@@ -147,9 +227,10 @@ if command -v enmetu >/dev/null 2>&1; then enmetu hook pre-commit || exit 1; els
|
|
|
147
227
|
## Known limitations
|
|
148
228
|
|
|
149
229
|
- One repository per process. No system service, no notifications.
|
|
150
|
-
- A partial staging pauses sync
|
|
151
|
-
- `commit-msg` and `prepare-commit-msg` hooks don't run on enmetu's commits.
|
|
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.
|
|
231
|
+
- `commit-msg` and `prepare-commit-msg` hooks don't run on enmetu's commits; `commitMessageCommand` writes the message instead.
|
|
152
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.
|
|
153
234
|
- A save during enmetu's pull can be overwritten by git without notice.
|
|
154
235
|
|
|
155
236
|
Overlapping git commands:
|
|
@@ -161,9 +242,17 @@ Overlapping git commands:
|
|
|
161
242
|
| `QUARANTINE_KEPT` | your edit collided with a version enmetu held aside | command in the log line |
|
|
162
243
|
| `COMMIT_ORPHANED` | committed during enmetu's pull | `git cherry-pick -x` command in `enmetu status` |
|
|
163
244
|
| `STAGED_REVERT` | a refused `git merge` restored old content | `git restore` command in the log line |
|
|
164
|
-
| `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>` |
|
|
165
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 |
|
|
166
247
|
|
|
248
|
+
## Contributing
|
|
249
|
+
|
|
250
|
+
We built enmetu for ourselves. We use it every day at [fluado](https://fluado.com) and we're sharing it because it might be useful to others.
|
|
251
|
+
|
|
252
|
+
This is not a community project. We don't have the bandwidth to review PRs, triage issues, or maintain a roadmap for external contributors. If you open an issue with a good idea, we might pick it up when it aligns with what we need. No promises.
|
|
253
|
+
|
|
254
|
+
Fork it, break it, make it yours. Have fun.
|
|
255
|
+
|
|
167
256
|
## Licence
|
|
168
257
|
|
|
169
258
|
MIT, Copyright (c) 2026 Yves Van Goethem, fluado UG (haftungsbeschränkt). See `LICENSE`.
|
|
@@ -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);
|
|
@@ -36,13 +36,16 @@ export class GitLocatorFacade {
|
|
|
36
36
|
if (insideWorkTree !== 'true') {
|
|
37
37
|
return { kind: 'not-a-repository' };
|
|
38
38
|
}
|
|
39
|
+
// Not --git-path hooks: it honours core.hooksPath, and git refuses an
|
|
40
|
+
// empty one as a path (exit 128) although it runs hooks with it; the
|
|
41
|
+
// installer writes only to the repository's own hooks directory, and only
|
|
42
|
+
// while core.hooksPath is unset (design 14.17).
|
|
39
43
|
const paths = parseRevParsePaths((await runOk(runner, [
|
|
40
44
|
'rev-parse',
|
|
41
45
|
'--path-format=absolute',
|
|
42
46
|
'--show-toplevel',
|
|
43
47
|
'--git-dir',
|
|
44
|
-
'--git-
|
|
45
|
-
'hooks',
|
|
48
|
+
'--git-common-dir',
|
|
46
49
|
])).stdout.toString('utf8'));
|
|
47
50
|
if (paths === null) {
|
|
48
51
|
throw new PreflightError('a repository path contains a newline, which enmetu does not support', 'PATH_UNSUPPORTED');
|
|
@@ -52,7 +55,7 @@ export class GitLocatorFacade {
|
|
|
52
55
|
paths: {
|
|
53
56
|
root: paths.root,
|
|
54
57
|
gitDir: paths.gitDir,
|
|
55
|
-
hooksDir: paths.
|
|
58
|
+
hooksDir: join(paths.commonDir, 'hooks'),
|
|
56
59
|
stateDir: join(paths.gitDir, 'enmetu'),
|
|
57
60
|
},
|
|
58
61
|
version,
|
|
@@ -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;
|