@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 CHANGED
@@ -1,7 +1,23 @@
1
- # enmetu
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
- Pin a version for repeatable runs: `npx @fluado/enmetu@0.1.0 run`.
37
+ As a project dependency: `npm install -D @fluado/enmetu`, then `npx enmetu run`.
20
38
 
21
- In a Node project: `npm install -D @fluado/enmetu`, then `npx enmetu run`.
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
- | `stagedSelection` | `"wait"` | with a partial staging: `"wait"`, or `"commit"` everything |
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 until you commit or unstage it (`stagedSelection: "wait"`).
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-path',
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.hooksDir,
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 config;
40
+ let parsed;
40
41
  try {
41
- config = parseConfig(await deps.fs.readText(configPath));
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
  }
@@ -4,7 +4,6 @@ import { EXIT } from '../contracts/exitCode.js';
4
4
  const EXIT_CODE_BY_ERROR = {
5
5
  USAGE: EXIT.USAGE,
6
6
  CONFIG_INVALID_JSON: EXIT.USAGE,
7
- CONFIG_UNKNOWN_KEY: EXIT.USAGE,
8
7
  CONFIG_INVALID_VALUE: EXIT.USAGE,
9
8
  HALTED: EXIT.HALTED,
10
9
  LOCK_HELD: EXIT.LOCKED,
@@ -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
- readonly stagedSelection: StagedSelectionMode;
37
- readonly stagedSelectionMaxWaitMs: number | null;
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. "wait"
97
- * without a reached cap returns waiting PARTIAL_SELECTION_STAGED; a reached
98
- * cap logs SELECTION_WAIT_ENDED and goes on; "commit" goes on.
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. "wait"
19
- * without a reached cap returns waiting PARTIAL_SELECTION_STAGED; a reached
20
- * cap logs SELECTION_WAIT_ENDED and goes on; "commit" goes on.
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
- if (staged) {
77
- if (settings.stagedSelection === 'wait') {
78
- since = memory.stagedSelectionSince ?? now;
79
- const cap = settings.stagedSelectionMaxWaitMs;
80
- if (cap === null || now - since < cap) {
81
- return {
82
- kind: 'waiting',
83
- reason: 'PARTIAL_SELECTION_STAGED',
84
- memory: rememberSince(memory, since),
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
- stagedSelectionSince: since,
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
- stagedSelectionSince: null,
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
- stagedSelectionSince: since,
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
- /** Effective hooks directory (honours core.hooksPath). */
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
- readonly stagedSelection: StagedSelectionMode;
14
- /** With stagedSelection "wait": commit anyway after this long; null waits without a cap. */
15
- readonly stagedSelectionMaxWaitMs: number | null;
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' | 'CONFIG_UNKNOWN_KEY' | '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';
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' | 'CONFIG_UNKNOWN_KEY' | 'CONFIG_INVALID_VALUE';
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 (stagedSelection "wait"), or a repository
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 stagedSelectionSince: number | null;
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,5 +1,5 @@
1
1
  export const EMPTY_COMMIT_MEMORY = {
2
- stagedSelectionSince: null,
2
+ partialStagingSince: null,
3
3
  rejection: null,
4
4
  residueTree: null,
5
5
  lastMove: 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
- stagedSelectionSince: memory.stagedSelectionSince,
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
- /** text null: the file is absent, every default applies. Throws ConfigError. */
5
- export declare function parseConfig(text: string | null): EnmetuConfig;
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
- stagedSelection: 'wait',
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
- 'stagedSelection',
29
- 'stagedSelectionMaxWaitMs',
27
+ 'partialStagingWaitMs',
30
28
  'commitHooks',
31
29
  'hookTimeoutMs',
32
30
  ];
33
31
  const MAX_SUBJECT_CHARACTERS = 100;
34
- /** The range of stagedSelectionMaxWaitMs and hookTimeoutMs: one second to one day (design 12.7). */
35
- const MIN_WAIT_MS = 1000;
36
- const MAX_WAIT_MS = 86400000;
37
- /** text null: the file is absent, every default applies. Throws ConfigError. */
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
- // Refused rather than ignored, so a misspelt key never falls back to its default.
47
- for (const key of Object.keys(parsed)) {
48
- if (!KNOWN_KEYS.includes(key)) {
49
- throw new ConfigError(`unknown key '${key}'`, 'CONFIG_UNKNOWN_KEY', key);
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 stagedSelection = readStagedSelection(parsed);
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
- stagedSelection,
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
- * stagedSelectionMaxWaitMs: null or an integer 1000..86400000, and only with
174
- * stagedSelection "wait" (a cap on "commit" is a contradiction, refused).
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 readStagedSelectionMaxWaitMs(parsed, mode) {
177
- const value = parsed.stagedSelectionMaxWaitMs;
178
- if (value === undefined || value === null) {
179
- return DEFAULT_CONFIG.stagedSelectionMaxWaitMs;
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 (mode !== 'wait') {
189
- throw invalid('stagedSelectionMaxWaitMs', 'is only valid with stagedSelection "wait"');
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', MIN_WAIT_MS, MAX_WAIT_MS);
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
- readonly hooksDir: string;
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, hooksDir] = lines;
177
- if (root === undefined || gitDir === undefined || hooksDir === 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, hooksDir };
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 is committed or unstaged, ${work}`;
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}`;
@@ -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
- stagedSelection: config.stagedSelection,
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 stagedSelectionSince): after the status read the repository
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 stagedSelectionSince): after the status read the repository
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
- stagedSelectionSince: null,
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 is committed or unstaged',
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
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fluado/enmetu",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Keeps one git working tree committed and in sync with its upstream.",
5
5
  "license": "MIT",
6
6
  "author": "fluado UG (haftungsbeschränkt)",