@fluado/enmetu 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +47 -6
- package/dist/adapters/git/gitLocatorFacade.js +6 -3
- package/dist/cli/commands/hookCommand.d.ts +1 -0
- package/dist/cli/commands/hookCommand.js +3 -1
- package/dist/cli/commands/runCommand.d.ts +2 -1
- package/dist/cli/commands/runCommand.js +13 -3
- package/dist/cli/exitCodes.js +0 -1
- package/dist/commit/commitLocalChangesController.d.ts +6 -6
- package/dist/commit/commitLocalChangesController.js +22 -23
- package/dist/contracts/IRepositoryLocator.d.ts +5 -1
- package/dist/contracts/config.d.ts +11 -5
- package/dist/contracts/errors.d.ts +2 -2
- package/dist/contracts/logCodes.d.ts +1 -1
- 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/config/parseConfig.d.ts +6 -3
- package/dist/core/config/parseConfig.js +26 -46
- package/dist/core/git/porcelain.d.ts +2 -1
- package/dist/core/git/porcelain.js +3 -3
- package/dist/core/status/statusHeadline.js +1 -1
- package/dist/enmetuInstances.js +1 -2
- package/dist/hooks/preCommitHookInstaller.js +10 -0
- 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,7 +1,23 @@
|
|
|
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>
|
|
2
6
|
|
|
3
7
|
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
8
|
|
|
9
|
+
## Why enmetu
|
|
10
|
+
|
|
11
|
+
Most auto-commit tools commit and push. enmetu also handles what goes wrong:
|
|
12
|
+
|
|
13
|
+
- **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.
|
|
14
|
+
- **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.
|
|
15
|
+
- **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.
|
|
16
|
+
- **Halts.** A rewritten upstream, or a state it cannot verify, halts it. The halt survives restarts until you run `enmetu resume`.
|
|
17
|
+
- **Status.** `enmetu status` says whether your work is committed and pushed, and during a conflict prints the commands to end it.
|
|
18
|
+
- **Dependencies.** None besides git. Runs under Node or Bun, as a command or as a library.
|
|
19
|
+
- **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.
|
|
20
|
+
|
|
5
21
|
## Prerequisites
|
|
6
22
|
|
|
7
23
|
- Node 24+ or Bun 1.3.6+
|
|
@@ -10,15 +26,31 @@ Commits and syncs a git working tree automatically. Run it inside a repository:
|
|
|
10
26
|
|
|
11
27
|
## Install
|
|
12
28
|
|
|
29
|
+
### Node
|
|
30
|
+
|
|
13
31
|
Inside a repository:
|
|
14
32
|
|
|
15
33
|
```sh
|
|
16
34
|
npx @fluado/enmetu run
|
|
17
35
|
```
|
|
18
36
|
|
|
19
|
-
|
|
37
|
+
As a project dependency: `npm install -D @fluado/enmetu`, then `npx enmetu run`.
|
|
20
38
|
|
|
21
|
-
|
|
39
|
+
### Bun
|
|
40
|
+
|
|
41
|
+
Inside a repository:
|
|
42
|
+
|
|
43
|
+
```sh
|
|
44
|
+
bunx --bun @fluado/enmetu run
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
As a project dependency: `bun add -d @fluado/enmetu`, then `bunx --bun enmetu run`.
|
|
48
|
+
|
|
49
|
+
Keep `--bun`: without it, `bunx` runs enmetu with Node when Node is installed.
|
|
50
|
+
|
|
51
|
+
### Pinning
|
|
52
|
+
|
|
53
|
+
Add the version for repeatable runs: `@fluado/enmetu@0.2.0`.
|
|
22
54
|
|
|
23
55
|
## Commands
|
|
24
56
|
|
|
@@ -64,11 +96,12 @@ const exitCode = await enmetu.stop()
|
|
|
64
96
|
| `skipJsonCheck` | `[]` | files exempt from the JSON check, such as `tsconfig.json` |
|
|
65
97
|
| `gitTimeoutMs` | `120000` | timeout per git command |
|
|
66
98
|
| `stateHistory` | `20` | state changes kept in the state file (0 to 100) |
|
|
67
|
-
| `
|
|
68
|
-
| `stagedSelectionMaxWaitMs` | `null` | with `"wait"`: commit everything after this long |
|
|
99
|
+
| `partialStagingWaitMs` | `300000` | with a partial staging: commit everything after this long; `0` at once |
|
|
69
100
|
| `commitHooks` | `true` | run your commit hooks on enmetu's commits |
|
|
70
101
|
| `hookTimeoutMs` | `600000` | timeout per hook |
|
|
71
102
|
|
|
103
|
+
Unknown settings are logged and ignored.
|
|
104
|
+
|
|
72
105
|
## Status
|
|
73
106
|
|
|
74
107
|
```
|
|
@@ -147,7 +180,7 @@ if command -v enmetu >/dev/null 2>&1; then enmetu hook pre-commit || exit 1; els
|
|
|
147
180
|
## Known limitations
|
|
148
181
|
|
|
149
182
|
- One repository per process. No system service, no notifications.
|
|
150
|
-
- A partial staging pauses sync
|
|
183
|
+
- 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.
|
|
151
184
|
- `commit-msg` and `prepare-commit-msg` hooks don't run on enmetu's commits.
|
|
152
185
|
- Under Bun without file-watching permission, enmetu commits on its poll interval.
|
|
153
186
|
- A save during enmetu's pull can be overwritten by git without notice.
|
|
@@ -164,6 +197,14 @@ Overlapping git commands:
|
|
|
164
197
|
| `could not write index` on a refused merge | git stashed your edits | `git fsck --no-reflogs --unreachable`, then `git stash apply <id>` |
|
|
165
198
|
| `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
199
|
|
|
200
|
+
## Contributing
|
|
201
|
+
|
|
202
|
+
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.
|
|
203
|
+
|
|
204
|
+
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.
|
|
205
|
+
|
|
206
|
+
Fork it, break it, make it yours. Have fun.
|
|
207
|
+
|
|
167
208
|
## Licence
|
|
168
209
|
|
|
169
210
|
MIT, Copyright (c) 2026 Yves Van Goethem, fluado UG (haftungsbeschränkt). See `LICENSE`.
|
|
@@ -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,
|
|
@@ -22,6 +22,7 @@ export interface HookCommandResult {
|
|
|
22
22
|
* 1. locator.locate(startDir); anything but found: notice, exit 0 (the hook fails open).
|
|
23
23
|
* 2. parseConfig of <root>/enmetu.config.json; a ConfigError prints a notice and
|
|
24
24
|
* applies the defaults, so a broken config never blocks the commit that fixes it.
|
|
25
|
+
* Unknown keys are ignored without a word: the daemon names them at start (design 14.17).
|
|
25
26
|
* 3. preCommitCheckController with the config's skipJsonCheck.
|
|
26
27
|
* 4. Violations: print the refusal of design 1.18 to stderr, exit 1. None: print nothing, exit 0.
|
|
27
28
|
*/
|
|
@@ -9,6 +9,7 @@ const HOOK_CYCLE_ID = 'hook';
|
|
|
9
9
|
* 1. locator.locate(startDir); anything but found: notice, exit 0 (the hook fails open).
|
|
10
10
|
* 2. parseConfig of <root>/enmetu.config.json; a ConfigError prints a notice and
|
|
11
11
|
* applies the defaults, so a broken config never blocks the commit that fixes it.
|
|
12
|
+
* Unknown keys are ignored without a word: the daemon names them at start (design 14.17).
|
|
12
13
|
* 3. preCommitCheckController with the config's skipJsonCheck.
|
|
13
14
|
* 4. Violations: print the refusal of design 1.18 to stderr, exit 1. None: print nothing, exit 0.
|
|
14
15
|
*/
|
|
@@ -57,7 +58,8 @@ async function locateOrReason(startDir, deps) {
|
|
|
57
58
|
}
|
|
58
59
|
async function configForCheck(root, deps) {
|
|
59
60
|
try {
|
|
60
|
-
return parseConfig(await deps.fs.readText(join(root, CONFIG_FILE_NAME)))
|
|
61
|
+
return parseConfig(await deps.fs.readText(join(root, CONFIG_FILE_NAME)))
|
|
62
|
+
.config;
|
|
61
63
|
}
|
|
62
64
|
catch (error) {
|
|
63
65
|
if (!(error instanceof ConfigError)) {
|
|
@@ -27,7 +27,8 @@ export declare const MIN_GIT_VERSION: {
|
|
|
27
27
|
* Controller:
|
|
28
28
|
* 1. locator.locate(startDir): not a repository, bare, or PATH_UNSUPPORTED log NOT_ELIGIBLE, exit 5.
|
|
29
29
|
* 2. Git older than 2.38: NOT_ELIGIBLE (GIT_TOO_OLD), exit 5.
|
|
30
|
-
* 3. parseConfig of <root>/enmetu.config.json: ConfigError logs CONFIG_INVALID, exit 2
|
|
30
|
+
* 3. parseConfig of <root>/enmetu.config.json: ConfigError logs CONFIG_INVALID, exit 2;
|
|
31
|
+
* each unknown key logs CONFIG_KEY_IGNORED once and is ignored (design 14.17).
|
|
31
32
|
* 4. createDaemon(paths, config).run(), its exit code returned.
|
|
32
33
|
*/
|
|
33
34
|
export declare function runCommand(input: RunCommandInput, deps: RunCommandDeps): Promise<RunCommandResult>;
|
|
@@ -8,7 +8,8 @@ export const MIN_GIT_VERSION = { major: 2, minor: 38 };
|
|
|
8
8
|
* Controller:
|
|
9
9
|
* 1. locator.locate(startDir): not a repository, bare, or PATH_UNSUPPORTED log NOT_ELIGIBLE, exit 5.
|
|
10
10
|
* 2. Git older than 2.38: NOT_ELIGIBLE (GIT_TOO_OLD), exit 5.
|
|
11
|
-
* 3. parseConfig of <root>/enmetu.config.json: ConfigError logs CONFIG_INVALID, exit 2
|
|
11
|
+
* 3. parseConfig of <root>/enmetu.config.json: ConfigError logs CONFIG_INVALID, exit 2;
|
|
12
|
+
* each unknown key logs CONFIG_KEY_IGNORED once and is ignored (design 14.17).
|
|
12
13
|
* 4. createDaemon(paths, config).run(), its exit code returned.
|
|
13
14
|
*/
|
|
14
15
|
export async function runCommand(input, deps) {
|
|
@@ -36,9 +37,9 @@ export async function runCommand(input, deps) {
|
|
|
36
37
|
}
|
|
37
38
|
// --- Step 3: the config at the resolved root, defaults when absent ---
|
|
38
39
|
const configPath = join(paths.root, CONFIG_FILE_NAME);
|
|
39
|
-
let
|
|
40
|
+
let parsed;
|
|
40
41
|
try {
|
|
41
|
-
|
|
42
|
+
parsed = parseConfig(await deps.fs.readText(configPath));
|
|
42
43
|
}
|
|
43
44
|
catch (error) {
|
|
44
45
|
if (!(error instanceof ConfigError)) {
|
|
@@ -51,6 +52,15 @@ export async function runCommand(input, deps) {
|
|
|
51
52
|
});
|
|
52
53
|
return { exitCode: EXIT.USAGE };
|
|
53
54
|
}
|
|
55
|
+
// A key only a newer enmetu knows must not stop this one (design 14.17).
|
|
56
|
+
for (const key of parsed.unknownKeys) {
|
|
57
|
+
deps.logger.log({
|
|
58
|
+
level: 'WARN',
|
|
59
|
+
code: 'CONFIG_KEY_IGNORED',
|
|
60
|
+
message: `${CONFIG_FILE_NAME}: unknown setting "${key}", ignored`,
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
const { config } = parsed;
|
|
54
64
|
// --- Step 4: the daemon for this repository ---
|
|
55
65
|
return { exitCode: await deps.createDaemon(paths, config).run() };
|
|
56
66
|
}
|
package/dist/cli/exitCodes.js
CHANGED
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import type { StagedSelectionMode } from '../contracts/config.js';
|
|
2
1
|
import type { IClock } from '../contracts/IClock.js';
|
|
3
2
|
import type { IFileSystem } from '../contracts/IFileSystem.js';
|
|
4
3
|
import type { IGitCommitter, IGitHookRunner } from '../contracts/IGitCommitter.js';
|
|
@@ -33,8 +32,8 @@ export interface CommitIndexGit {
|
|
|
33
32
|
export interface CommitSettings {
|
|
34
33
|
readonly commitSubject: string;
|
|
35
34
|
readonly skipJsonCheck: readonly string[];
|
|
36
|
-
|
|
37
|
-
readonly
|
|
35
|
+
/** How long a partial staging holds the commit back; 0 commits at once (design 14.17). */
|
|
36
|
+
readonly partialStagingWaitMs: number;
|
|
38
37
|
readonly commitHooks: boolean;
|
|
39
38
|
/** <gitDir>/index, copied to seed the commit index with its stat data. */
|
|
40
39
|
readonly realIndex: string;
|
|
@@ -93,9 +92,10 @@ export type CommitLocalChanges = (input: CommitLocalChangesInput) => Promise<Com
|
|
|
93
92
|
* differs and memory.lastMove is set, repairStaleIndexController first
|
|
94
93
|
* (Decision 98): moved, repaired or reverted (Decision 108) forgets lastMove; repaired reads
|
|
95
94
|
* before and the verdict again, so a stale index is never taken for a
|
|
96
|
-
* person's selection.
|
|
97
|
-
*
|
|
98
|
-
*
|
|
95
|
+
* person's selection. While less than partialStagingWaitMs has passed
|
|
96
|
+
* since memory.partialStagingSince (the first cycle that saw the staging)
|
|
97
|
+
* it returns waiting PARTIAL_SELECTION_STAGED; once it has passed it logs
|
|
98
|
+
* SELECTION_WAIT_ENDED and goes on; 0 goes on at once (design 14.17).
|
|
99
99
|
* 2. Build: parent = git.headCommit(); fs.copy(realIndex, commit index);
|
|
100
100
|
* committer.seedFrom(parent); stageGuardedChanges on the commit index.
|
|
101
101
|
* Nothing staged returns nothing.
|
|
@@ -15,9 +15,10 @@ export const HOOK_RETRY_MS = 600000;
|
|
|
15
15
|
* differs and memory.lastMove is set, repairStaleIndexController first
|
|
16
16
|
* (Decision 98): moved, repaired or reverted (Decision 108) forgets lastMove; repaired reads
|
|
17
17
|
* before and the verdict again, so a stale index is never taken for a
|
|
18
|
-
* person's selection.
|
|
19
|
-
*
|
|
20
|
-
*
|
|
18
|
+
* person's selection. While less than partialStagingWaitMs has passed
|
|
19
|
+
* since memory.partialStagingSince (the first cycle that saw the staging)
|
|
20
|
+
* it returns waiting PARTIAL_SELECTION_STAGED; once it has passed it logs
|
|
21
|
+
* SELECTION_WAIT_ENDED and goes on; 0 goes on at once (design 14.17).
|
|
21
22
|
* 2. Build: parent = git.headCommit(); fs.copy(realIndex, commit index);
|
|
22
23
|
* committer.seedFrom(parent); stageGuardedChanges on the commit index.
|
|
23
24
|
* Nothing staged returns nothing.
|
|
@@ -73,24 +74,22 @@ export async function commitLocalChangesController(input, deps) {
|
|
|
73
74
|
}
|
|
74
75
|
}
|
|
75
76
|
let since = null;
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
};
|
|
86
|
-
}
|
|
87
|
-
deps.logger.log({
|
|
88
|
-
level: 'WARN',
|
|
89
|
-
code: 'SELECTION_WAIT_ENDED',
|
|
90
|
-
message: `a staged selection waited ${cap} ms, the configured cap; committing everything, the selection included`,
|
|
91
|
-
cycleId,
|
|
92
|
-
});
|
|
77
|
+
const wait = settings.partialStagingWaitMs;
|
|
78
|
+
if (staged && wait > 0) {
|
|
79
|
+
since = memory.partialStagingSince ?? now;
|
|
80
|
+
if (now - since < wait) {
|
|
81
|
+
return {
|
|
82
|
+
kind: 'waiting',
|
|
83
|
+
reason: 'PARTIAL_SELECTION_STAGED',
|
|
84
|
+
memory: rememberSince(memory, since),
|
|
85
|
+
};
|
|
93
86
|
}
|
|
87
|
+
deps.logger.log({
|
|
88
|
+
level: 'WARN',
|
|
89
|
+
code: 'SELECTION_WAIT_ENDED',
|
|
90
|
+
message: `a staged selection waited ${wait} ms, the configured cap; committing everything, the selection included`,
|
|
91
|
+
cycleId,
|
|
92
|
+
});
|
|
94
93
|
}
|
|
95
94
|
const unchanged = rememberSince(memory, since);
|
|
96
95
|
// --- Step 2: build on the commit index, seeded from HEAD ---
|
|
@@ -131,7 +130,7 @@ export async function commitLocalChangesController(input, deps) {
|
|
|
131
130
|
kind: 'waiting',
|
|
132
131
|
reason: 'COMMIT_HOOK_REJECTED',
|
|
133
132
|
memory: {
|
|
134
|
-
|
|
133
|
+
partialStagingSince: since,
|
|
135
134
|
rejection: { tree, at: now },
|
|
136
135
|
residueTree: memory.residueTree,
|
|
137
136
|
lastMove: memory.lastMove,
|
|
@@ -178,7 +177,7 @@ export async function commitLocalChangesController(input, deps) {
|
|
|
178
177
|
return {
|
|
179
178
|
kind: 'committed',
|
|
180
179
|
memory: {
|
|
181
|
-
|
|
180
|
+
partialStagingSince: null,
|
|
182
181
|
rejection: null,
|
|
183
182
|
residueTree,
|
|
184
183
|
lastMove: { from: parent, to: commit },
|
|
@@ -347,7 +346,7 @@ async function rollBack(branchRef, commit, parent, cycleId, deps) {
|
|
|
347
346
|
}
|
|
348
347
|
function rememberSince(memory, since) {
|
|
349
348
|
return {
|
|
350
|
-
|
|
349
|
+
partialStagingSince: since,
|
|
351
350
|
rejection: memory.rejection,
|
|
352
351
|
residueTree: memory.residueTree,
|
|
353
352
|
lastMove: memory.lastMove,
|
|
@@ -3,7 +3,11 @@ export interface RepositoryPaths {
|
|
|
3
3
|
readonly root: string;
|
|
4
4
|
/** Per worktree; state files live under it. */
|
|
5
5
|
readonly gitDir: string;
|
|
6
|
-
/**
|
|
6
|
+
/**
|
|
7
|
+
* The repository's own hooks directory, <git common dir>/hooks, whatever
|
|
8
|
+
* core.hooksPath says; git runs hooks from it only while core.hooksPath is
|
|
9
|
+
* unset, which is also the only case the installer writes there (design 14.17).
|
|
10
|
+
*/
|
|
7
11
|
readonly hooksDir: string;
|
|
8
12
|
/** <gitDir>/enmetu */
|
|
9
13
|
readonly stateDir: string;
|
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
/** What enmetu does while a person has a selection staged (design 12.7). */
|
|
2
|
-
export type StagedSelectionMode = 'wait' | 'commit';
|
|
3
1
|
export interface EnmetuConfig {
|
|
4
2
|
readonly quietMs: number;
|
|
5
3
|
readonly maxDelayMs: number;
|
|
@@ -10,11 +8,19 @@ export interface EnmetuConfig {
|
|
|
10
8
|
readonly gitTimeoutMs: number;
|
|
11
9
|
/** Transitions kept in state.json; 0 keeps none (design 12.1). */
|
|
12
10
|
readonly stateHistory: number;
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
11
|
+
/**
|
|
12
|
+
* While a person has a partial staging, how long enmetu waits before it
|
|
13
|
+
* commits everything, the staging included; 0 commits at once (design 14.17).
|
|
14
|
+
*/
|
|
15
|
+
readonly partialStagingWaitMs: number;
|
|
16
16
|
/** Run the repository's commit hooks for enmetu's commits (design 12.7). */
|
|
17
17
|
readonly commitHooks: boolean;
|
|
18
18
|
/** How long one repository hook may run before it is a rejection (design 12.7, Decision 83). */
|
|
19
19
|
readonly hookTimeoutMs: number;
|
|
20
20
|
}
|
|
21
|
+
/** What parseConfig read: the config, and the keys it does not know, in file order (design 14.17). */
|
|
22
|
+
export interface ParsedConfig {
|
|
23
|
+
readonly config: EnmetuConfig;
|
|
24
|
+
/** Ignored; the run command names each once at start. */
|
|
25
|
+
readonly unknownKeys: readonly string[];
|
|
26
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { HaltCode } from './IHaltStore.js';
|
|
2
|
-
export type EnmetuErrorCode = 'GIT_NOT_FOUND' | 'GIT_TIMEOUT' | 'GIT_ABORTED' | 'GIT_FAILED' | 'GIT_INDEX_LOCKED' | 'GIT_OUTPUT_UNPARSEABLE' | 'CONFIG_INVALID_JSON' | '
|
|
2
|
+
export type EnmetuErrorCode = 'GIT_NOT_FOUND' | 'GIT_TIMEOUT' | 'GIT_ABORTED' | 'GIT_FAILED' | 'GIT_INDEX_LOCKED' | 'GIT_OUTPUT_UNPARSEABLE' | 'CONFIG_INVALID_JSON' | 'CONFIG_INVALID_VALUE' | 'NOT_A_REPOSITORY' | 'BARE_REPOSITORY' | 'GIT_TOO_OLD' | 'PATH_UNSUPPORTED' | 'OPERATION_IN_PROGRESS' | 'DETACHED_HEAD' | 'HEAD_UNBORN' | 'NO_UPSTREAM' | 'UPSTREAM_NOT_REMOTE' | 'IDENTITY_MISSING' | 'LOCK_HELD' | 'LOCK_UNREADABLE' | 'HALTED' | 'USAGE';
|
|
3
3
|
export type GitCommandErrorCode = 'GIT_NOT_FOUND' | 'GIT_TIMEOUT' | 'GIT_ABORTED' | 'GIT_FAILED' | 'GIT_INDEX_LOCKED';
|
|
4
|
-
export type ConfigErrorCode = 'CONFIG_INVALID_JSON' | '
|
|
4
|
+
export type ConfigErrorCode = 'CONFIG_INVALID_JSON' | 'CONFIG_INVALID_VALUE';
|
|
5
5
|
export type PreflightErrorCode = 'NOT_A_REPOSITORY' | 'BARE_REPOSITORY' | 'GIT_TOO_OLD' | 'PATH_UNSUPPORTED' | 'OPERATION_IN_PROGRESS' | 'DETACHED_HEAD' | 'HEAD_UNBORN' | 'NO_UPSTREAM' | 'UPSTREAM_NOT_REMOTE' | 'IDENTITY_MISSING';
|
|
6
6
|
export type LockErrorCode = 'LOCK_HELD' | 'LOCK_UNREADABLE';
|
|
7
7
|
export declare class EnmetuError extends Error {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
export type LogCode = 'STARTED' | 'STOPPING' | 'STOPPED' | 'LOCK_STALE_TAKEN' | 'RECOVERED' | 'HOOK_INSTALLED' | 'HOOK_UPDATED' | 'HOOK_SKIPPED' | 'HOOK_FAILED' | 'WATCHER_FAILED' | 'COMMITTED' | 'STAGE_PARTIAL' | 'EXCLUDED' | 'PULLED' | 'PUSHED' | 'PUSH_REJECTED' | 'PUSH_FAILED' | 'OFFLINE' | 'CONFLICT' | 'UNPUSHED' | 'PAUSED' | 'BUSY' | 'RESUMED' | 'QUARANTINE_KEPT' | 'HALTED' | 'CYCLE_FAILED' | 'NOT_ELIGIBLE' | 'LOCKED' | 'CONFIG_INVALID' | 'STATE_WRITE_FAILED' | 'WAITING' | 'SELECTION_WAIT_ENDED' | 'HOOK_REJECTED' | 'COMMIT_DISCARDED' | 'INDEX_REPAIRED' | 'QUARANTINE_UPSTREAM_CHANGED' | 'REBASE_NOT_STARTED' | 'STAGED_RESET' | 'STAGED_REVERT' | 'REBASE_BRANCH_MOVED' | 'COMMIT_ORPHANED';
|
|
1
|
+
export type LogCode = 'STARTED' | 'STOPPING' | 'STOPPED' | 'LOCK_STALE_TAKEN' | 'RECOVERED' | 'HOOK_INSTALLED' | 'HOOK_UPDATED' | 'HOOK_SKIPPED' | 'HOOK_FAILED' | 'WATCHER_FAILED' | 'COMMITTED' | 'STAGE_PARTIAL' | 'EXCLUDED' | 'PULLED' | 'PUSHED' | 'PUSH_REJECTED' | 'PUSH_FAILED' | 'OFFLINE' | 'CONFLICT' | 'UNPUSHED' | 'PAUSED' | 'BUSY' | 'RESUMED' | 'QUARANTINE_KEPT' | 'HALTED' | 'CYCLE_FAILED' | 'NOT_ELIGIBLE' | 'LOCKED' | 'CONFIG_INVALID' | 'CONFIG_KEY_IGNORED' | 'STATE_WRITE_FAILED' | 'WAITING' | 'SELECTION_WAIT_ENDED' | 'HOOK_REJECTED' | 'COMMIT_DISCARDED' | 'INDEX_REPAIRED' | 'QUARANTINE_UPSTREAM_CHANGED' | 'REBASE_NOT_STARTED' | 'STAGED_RESET' | 'STAGED_REVERT' | 'REBASE_BRANCH_MOVED' | 'COMMIT_ORPHANED';
|
|
@@ -12,8 +12,8 @@ export type PauseReason = 'FOREIGN_OPERATION' | 'DETACHED_HEAD' | 'NO_UPSTREAM'
|
|
|
12
12
|
| 'REBASE_REFUSED';
|
|
13
13
|
/**
|
|
14
14
|
* Why a cycle committed nothing although the tree has changes (design 12.7):
|
|
15
|
-
* a person has a selection staged
|
|
16
|
-
* commit hook rejected enmetu's commit.
|
|
15
|
+
* a person has a selection staged and partialStagingWaitMs has not passed
|
|
16
|
+
* (design 14.17), or a repository commit hook rejected enmetu's commit.
|
|
17
17
|
*/
|
|
18
18
|
export type WaitReason = 'PARTIAL_SELECTION_STAGED' | 'COMMIT_HOOK_REJECTED';
|
|
19
19
|
export type CycleOutcome = {
|
|
@@ -61,7 +61,7 @@ export interface HeadMove {
|
|
|
61
61
|
*/
|
|
62
62
|
export interface CommitMemory {
|
|
63
63
|
/** Epoch ms of the first cycle that found the real index differing from HEAD; null when it does not. */
|
|
64
|
-
readonly
|
|
64
|
+
readonly partialStagingSince: number | null;
|
|
65
65
|
readonly rejection: HookRejection | null;
|
|
66
66
|
/** The tree a fresh build gave right after enmetu's last commit with hooks; null when hooks left nothing. */
|
|
67
67
|
readonly residueTree: string | null;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/** The memory with lastMove replaced, every other field kept (Decision 98); the commit step and the cycle both set it. */
|
|
2
2
|
export function withLastMove(memory, lastMove) {
|
|
3
3
|
return {
|
|
4
|
-
|
|
4
|
+
partialStagingSince: memory.partialStagingSince,
|
|
5
5
|
rejection: memory.rejection,
|
|
6
6
|
residueTree: memory.residueTree,
|
|
7
7
|
lastMove,
|
|
@@ -1,5 +1,8 @@
|
|
|
1
|
-
import type { EnmetuConfig } from '../../contracts/config.js';
|
|
1
|
+
import type { EnmetuConfig, ParsedConfig } from '../../contracts/config.js';
|
|
2
2
|
export declare const CONFIG_FILE_NAME = "enmetu.config.json";
|
|
3
3
|
export declare const DEFAULT_CONFIG: EnmetuConfig;
|
|
4
|
-
/**
|
|
5
|
-
|
|
4
|
+
/**
|
|
5
|
+
* text null: the file is absent, every default applies. Throws ConfigError
|
|
6
|
+
* for text that does not parse and for an invalid value of a known key.
|
|
7
|
+
*/
|
|
8
|
+
export declare function parseConfig(text: string | null): ParsedConfig;
|
|
@@ -11,8 +11,7 @@ export const DEFAULT_CONFIG = {
|
|
|
11
11
|
skipJsonCheck: [],
|
|
12
12
|
gitTimeoutMs: 120000,
|
|
13
13
|
stateHistory: 20,
|
|
14
|
-
|
|
15
|
-
stagedSelectionMaxWaitMs: null,
|
|
14
|
+
partialStagingWaitMs: 300000,
|
|
16
15
|
commitHooks: true,
|
|
17
16
|
hookTimeoutMs: 600000,
|
|
18
17
|
};
|
|
@@ -25,33 +24,32 @@ const KNOWN_KEYS = [
|
|
|
25
24
|
'skipJsonCheck',
|
|
26
25
|
'gitTimeoutMs',
|
|
27
26
|
'stateHistory',
|
|
28
|
-
'
|
|
29
|
-
'stagedSelectionMaxWaitMs',
|
|
27
|
+
'partialStagingWaitMs',
|
|
30
28
|
'commitHooks',
|
|
31
29
|
'hookTimeoutMs',
|
|
32
30
|
];
|
|
33
31
|
const MAX_SUBJECT_CHARACTERS = 100;
|
|
34
|
-
/** The range of
|
|
35
|
-
const
|
|
36
|
-
const
|
|
37
|
-
/**
|
|
32
|
+
/** The range of hookTimeoutMs: one second to one day (design 12.7). */
|
|
33
|
+
const MIN_HOOK_TIMEOUT_MS = 1000;
|
|
34
|
+
const MAX_HOOK_TIMEOUT_MS = 86400000;
|
|
35
|
+
/**
|
|
36
|
+
* text null: the file is absent, every default applies. Throws ConfigError
|
|
37
|
+
* for text that does not parse and for an invalid value of a known key.
|
|
38
|
+
*/
|
|
38
39
|
export function parseConfig(text) {
|
|
39
40
|
if (text === null) {
|
|
40
|
-
return DEFAULT_CONFIG;
|
|
41
|
+
return { config: DEFAULT_CONFIG, unknownKeys: [] };
|
|
41
42
|
}
|
|
42
43
|
const parsed = parseJson(text);
|
|
43
44
|
if (!isRecord(parsed)) {
|
|
44
45
|
throw new ConfigError('the top level must be a JSON object', 'CONFIG_INVALID_VALUE', null);
|
|
45
46
|
}
|
|
46
|
-
//
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
}
|
|
51
|
-
}
|
|
47
|
+
// Ignored rather than refused, so a key only a newer enmetu knows never
|
|
48
|
+
// stops an older one; the caller names each, so a typo is not silent
|
|
49
|
+
// (design 14.17).
|
|
50
|
+
const unknownKeys = Object.keys(parsed).filter((key) => !KNOWN_KEYS.includes(key));
|
|
52
51
|
const quietMs = readInteger(parsed, 'quietMs', 100, 600000);
|
|
53
|
-
const
|
|
54
|
-
return {
|
|
52
|
+
const config = {
|
|
55
53
|
quietMs,
|
|
56
54
|
maxDelayMs: readInteger(parsed, 'maxDelayMs', quietMs, 3600000),
|
|
57
55
|
pollMs: readInteger(parsed, 'pollMs', 100, 3600000),
|
|
@@ -60,11 +58,11 @@ export function parseConfig(text) {
|
|
|
60
58
|
skipJsonCheck: readSkipJsonCheck(parsed),
|
|
61
59
|
gitTimeoutMs: readInteger(parsed, 'gitTimeoutMs', 1000, 3600000),
|
|
62
60
|
stateHistory: readStateHistory(parsed),
|
|
63
|
-
|
|
64
|
-
stagedSelectionMaxWaitMs: readStagedSelectionMaxWaitMs(parsed, stagedSelection),
|
|
61
|
+
partialStagingWaitMs: readPartialStagingWaitMs(parsed),
|
|
65
62
|
commitHooks: readBoolean(parsed, 'commitHooks'),
|
|
66
63
|
hookTimeoutMs: readHookTimeoutMs(parsed),
|
|
67
64
|
};
|
|
65
|
+
return { config, unknownKeys };
|
|
68
66
|
}
|
|
69
67
|
function parseJson(text) {
|
|
70
68
|
try {
|
|
@@ -158,39 +156,21 @@ function readSkipJsonCheck(parsed) {
|
|
|
158
156
|
function readStateHistory(parsed) {
|
|
159
157
|
return readInteger(parsed, 'stateHistory', 0, MAX_STATE_HISTORY);
|
|
160
158
|
}
|
|
161
|
-
/** stagedSelection: "wait" or "commit" (design 12.7). */
|
|
162
|
-
function readStagedSelection(parsed) {
|
|
163
|
-
const value = parsed.stagedSelection;
|
|
164
|
-
if (value === undefined) {
|
|
165
|
-
return DEFAULT_CONFIG.stagedSelection;
|
|
166
|
-
}
|
|
167
|
-
if (value === 'wait' || value === 'commit') {
|
|
168
|
-
return value;
|
|
169
|
-
}
|
|
170
|
-
throw invalid('stagedSelection', 'must be "wait" or "commit"');
|
|
171
|
-
}
|
|
172
159
|
/**
|
|
173
|
-
*
|
|
174
|
-
*
|
|
160
|
+
* partialStagingWaitMs: an integer of 0 or more (design 14.17). There is no
|
|
161
|
+
* wait without an end, so null is refused like any other non-integer.
|
|
175
162
|
*/
|
|
176
|
-
function
|
|
177
|
-
const value = parsed.
|
|
178
|
-
if (value === undefined
|
|
179
|
-
return DEFAULT_CONFIG.
|
|
180
|
-
}
|
|
181
|
-
const rule = `must be null or an integer from ${MIN_WAIT_MS} to ${MAX_WAIT_MS}`;
|
|
182
|
-
if (typeof value !== 'number' ||
|
|
183
|
-
!Number.isInteger(value) ||
|
|
184
|
-
value < MIN_WAIT_MS ||
|
|
185
|
-
value > MAX_WAIT_MS) {
|
|
186
|
-
throw invalid('stagedSelectionMaxWaitMs', rule);
|
|
163
|
+
function readPartialStagingWaitMs(parsed) {
|
|
164
|
+
const value = parsed.partialStagingWaitMs;
|
|
165
|
+
if (value === undefined) {
|
|
166
|
+
return DEFAULT_CONFIG.partialStagingWaitMs;
|
|
187
167
|
}
|
|
188
|
-
if (
|
|
189
|
-
throw invalid('
|
|
168
|
+
if (typeof value !== 'number' || !Number.isInteger(value) || value < 0) {
|
|
169
|
+
throw invalid('partialStagingWaitMs', 'must be an integer of 0 or more');
|
|
190
170
|
}
|
|
191
171
|
return value;
|
|
192
172
|
}
|
|
193
173
|
/** hookTimeoutMs: integer 1000..86400000 (design 12.7, Decision 83). */
|
|
194
174
|
function readHookTimeoutMs(parsed) {
|
|
195
|
-
return readInteger(parsed, 'hookTimeoutMs',
|
|
175
|
+
return readInteger(parsed, 'hookTimeoutMs', MIN_HOOK_TIMEOUT_MS, MAX_HOOK_TIMEOUT_MS);
|
|
196
176
|
}
|
|
@@ -27,7 +27,8 @@ export interface BatchBlob {
|
|
|
27
27
|
export interface RevParsePaths {
|
|
28
28
|
readonly root: string;
|
|
29
29
|
readonly gitDir: string;
|
|
30
|
-
|
|
30
|
+
/** --git-common-dir: the main repository's git dir, also from a linked worktree. */
|
|
31
|
+
readonly commonDir: string;
|
|
31
32
|
}
|
|
32
33
|
/** `git status --porcelain=v1 -z` records; a rename or copy record consumes its source path field. */
|
|
33
34
|
export declare function parseStatus(stdout: Buffer): readonly StatusEntry[];
|
|
@@ -173,11 +173,11 @@ export function parseRevParsePaths(stdout) {
|
|
|
173
173
|
if (lines.length !== 3 || !lines.every((line) => line.startsWith('/'))) {
|
|
174
174
|
return null;
|
|
175
175
|
}
|
|
176
|
-
const [root, gitDir,
|
|
177
|
-
if (root === undefined || gitDir === undefined ||
|
|
176
|
+
const [root, gitDir, commonDir] = lines;
|
|
177
|
+
if (root === undefined || gitDir === undefined || commonDir === undefined) {
|
|
178
178
|
return null;
|
|
179
179
|
}
|
|
180
|
-
return { root, gitDir,
|
|
180
|
+
return { root, gitDir, commonDir };
|
|
181
181
|
}
|
|
182
182
|
/** true when `git push --porcelain` output carries a ref line starting with `!`. */
|
|
183
183
|
export function hasRejectedRef(stdout) {
|
|
@@ -64,7 +64,7 @@ function runningHeadline(record, uncommitted, unpushed) {
|
|
|
64
64
|
return `attention: pushes failing since ${since}, ${work}`;
|
|
65
65
|
case 'waiting':
|
|
66
66
|
if (record.reason === 'PARTIAL_SELECTION_STAGED') {
|
|
67
|
-
return `waiting: a partial selection is staged since ${since}; nothing is committed or pulled until it
|
|
67
|
+
return `waiting: a partial selection is staged since ${since}; nothing is committed or pulled until you commit or unstage it, and after partialStagingWaitMs enmetu commits everything, ${work}`;
|
|
68
68
|
}
|
|
69
69
|
if (record.reason === 'COMMIT_HOOK_REJECTED') {
|
|
70
70
|
return `waiting: a commit hook rejected the last commit since ${since}, ${work}`;
|
package/dist/enmetuInstances.js
CHANGED
|
@@ -292,8 +292,7 @@ function commitOf(parts, paths, config, runtime, hookStop) {
|
|
|
292
292
|
settings: {
|
|
293
293
|
commitSubject: config.commitSubject,
|
|
294
294
|
skipJsonCheck: config.skipJsonCheck,
|
|
295
|
-
|
|
296
|
-
stagedSelectionMaxWaitMs: config.stagedSelectionMaxWaitMs,
|
|
295
|
+
partialStagingWaitMs: config.partialStagingWaitMs,
|
|
297
296
|
commitHooks: config.commitHooks,
|
|
298
297
|
realIndex: join(paths.gitDir, 'index'),
|
|
299
298
|
root: paths.root,
|
|
@@ -45,6 +45,16 @@ export class PreCommitHookInstaller {
|
|
|
45
45
|
// repository sharing it); writing there would install enmetu into all
|
|
46
46
|
// of them, so the person adds the line themselves.
|
|
47
47
|
const hooksPath = await this.config.configValue('core.hooksPath');
|
|
48
|
+
if (hooksPath === '') {
|
|
49
|
+
// git looks for an empty hooks path's hooks at /<name>, so no hook of
|
|
50
|
+
// the repository runs (measured with git 2.52.0, design 14.17); there
|
|
51
|
+
// is no hook to add the line to.
|
|
52
|
+
return {
|
|
53
|
+
kind: 'skipped',
|
|
54
|
+
reason: 'HOOKS_PATH_SET',
|
|
55
|
+
instruction: 'core.hooksPath is empty, so git runs no hooks and enmetu installs none; unset it to have enmetu install its pre-commit check.',
|
|
56
|
+
};
|
|
57
|
+
}
|
|
48
58
|
if (hooksPath !== null) {
|
|
49
59
|
return {
|
|
50
60
|
kind: 'skipped',
|
|
@@ -37,7 +37,7 @@ export interface SyncCycleDeps {
|
|
|
37
37
|
* a local one (remote '.') pauses UPSTREAM_NOT_REMOTE (Decision 28).
|
|
38
38
|
* 4. Pending quarantine memento: restore it; failed halts QUARANTINE_RESTORE_FAILED.
|
|
39
39
|
* 5. Commit local changes, skipped on a clean status (which also clears the
|
|
40
|
-
* memory's
|
|
40
|
+
* memory's partialStagingSince): after the status read the repository
|
|
41
41
|
* state is read again (Decision 52), then deps.commit runs with the commit
|
|
42
42
|
* memory (design 12.7); busy and paused return, committed and nothing go
|
|
43
43
|
* on, waiting goes on and is the outcome unless a later step returns
|
|
@@ -23,7 +23,7 @@ export const REBASE_REFUSED_AFTER = 3;
|
|
|
23
23
|
* a local one (remote '.') pauses UPSTREAM_NOT_REMOTE (Decision 28).
|
|
24
24
|
* 4. Pending quarantine memento: restore it; failed halts QUARANTINE_RESTORE_FAILED.
|
|
25
25
|
* 5. Commit local changes, skipped on a clean status (which also clears the
|
|
26
|
-
* memory's
|
|
26
|
+
* memory's partialStagingSince): after the status read the repository
|
|
27
27
|
* state is read again (Decision 52), then deps.commit runs with the commit
|
|
28
28
|
* memory (design 12.7); busy and paused return, committed and nothing go
|
|
29
29
|
* on, waiting goes on and is the outcome unless a later step returns
|
|
@@ -143,7 +143,7 @@ async function runCycle(input, deps, carried, outcome) {
|
|
|
143
143
|
else if (carried.commitMemory !== undefined) {
|
|
144
144
|
// A clean tree has no staged selection, so a wait for one is over.
|
|
145
145
|
carried.commitMemory = {
|
|
146
|
-
|
|
146
|
+
partialStagingSince: null,
|
|
147
147
|
rejection: carried.commitMemory.rejection,
|
|
148
148
|
residueTree: carried.commitMemory.residueTree,
|
|
149
149
|
lastMove: carried.commitMemory.lastMove,
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { EMPTY_COMMIT_MEMORY, } from '../contracts/syncTypes.js';
|
|
2
2
|
import { errorMessage } from '../core/errors/errorMessage.js';
|
|
3
3
|
const WAITING_MESSAGES = {
|
|
4
|
-
PARTIAL_SELECTION_STAGED: 'waiting: a partial selection is staged; nothing is committed or pulled until it
|
|
4
|
+
PARTIAL_SELECTION_STAGED: 'waiting: a partial selection is staged; nothing is committed or pulled until you commit or unstage it, and after partialStagingWaitMs enmetu commits everything',
|
|
5
5
|
COMMIT_HOOK_REJECTED: 'waiting: a commit hook rejected the commit; enmetu tries again when the files change',
|
|
6
6
|
};
|
|
7
7
|
/**
|