@fluado/enmetu 0.0.0-stage → 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.
Files changed (209) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +209 -2
  3. package/dist/adapters/clock/systemClockAdapter.d.ts +6 -0
  4. package/dist/adapters/clock/systemClockAdapter.js +12 -0
  5. package/dist/adapters/fs/nodeFileSystemAdapter.d.ts +18 -0
  6. package/dist/adapters/fs/nodeFileSystemAdapter.js +230 -0
  7. package/dist/adapters/git/gitFacade.d.ts +148 -0
  8. package/dist/adapters/git/gitFacade.js +822 -0
  9. package/dist/adapters/git/gitLocatorFacade.d.ts +8 -0
  10. package/dist/adapters/git/gitLocatorFacade.js +75 -0
  11. package/dist/adapters/git/gitProcessAdapter.d.ts +46 -0
  12. package/dist/adapters/git/gitProcessAdapter.js +261 -0
  13. package/dist/adapters/log/nullLogger.d.ts +9 -0
  14. package/dist/adapters/log/nullLogger.js +10 -0
  15. package/dist/adapters/log/stderrLoggerAdapter.d.ts +10 -0
  16. package/dist/adapters/log/stderrLoggerAdapter.js +13 -0
  17. package/dist/adapters/process/nodeProcessAdapter.d.ts +9 -0
  18. package/dist/adapters/process/nodeProcessAdapter.js +56 -0
  19. package/dist/adapters/watch/fsWatchAdapter.d.ts +24 -0
  20. package/dist/adapters/watch/fsWatchAdapter.js +47 -0
  21. package/dist/cli/commands/hookCommand.d.ts +29 -0
  22. package/dist/cli/commands/hookCommand.js +92 -0
  23. package/dist/cli/commands/resumeCommand.d.ts +48 -0
  24. package/dist/cli/commands/resumeCommand.js +124 -0
  25. package/dist/cli/commands/runCommand.d.ts +34 -0
  26. package/dist/cli/commands/runCommand.js +79 -0
  27. package/dist/cli/commands/statusCommand.d.ts +71 -0
  28. package/dist/cli/commands/statusCommand.js +249 -0
  29. package/dist/cli/exitCodes.d.ts +7 -0
  30. package/dist/cli/exitCodes.js +38 -0
  31. package/dist/cli/helpText.d.ts +1 -0
  32. package/dist/cli/helpText.js +16 -0
  33. package/dist/cli/main.d.ts +8 -0
  34. package/dist/cli/main.js +49 -0
  35. package/dist/cli/parseArgs.d.ts +19 -0
  36. package/dist/cli/parseArgs.js +106 -0
  37. package/dist/cli.d.ts +2 -0
  38. package/dist/cli.js +32 -0
  39. package/dist/commit/commitLocalChangesController.d.ts +132 -0
  40. package/dist/commit/commitLocalChangesController.js +357 -0
  41. package/dist/commit/gitIndexLock.d.ts +36 -0
  42. package/dist/commit/gitIndexLock.js +90 -0
  43. package/dist/commit/repairStaleIndexController.d.ts +90 -0
  44. package/dist/commit/repairStaleIndexController.js +150 -0
  45. package/dist/commit/stageGuardedChanges.d.ts +30 -0
  46. package/dist/commit/stageGuardedChanges.js +52 -0
  47. package/dist/contracts/IClock.d.ts +7 -0
  48. package/dist/contracts/IClock.js +1 -0
  49. package/dist/contracts/IDaemon.d.ts +26 -0
  50. package/dist/contracts/IDaemon.js +1 -0
  51. package/dist/contracts/IFileSystem.d.ts +50 -0
  52. package/dist/contracts/IFileSystem.js +1 -0
  53. package/dist/contracts/IGitCommitter.d.ts +60 -0
  54. package/dist/contracts/IGitCommitter.js +1 -0
  55. package/dist/contracts/IGitConfigReader.d.ts +5 -0
  56. package/dist/contracts/IGitConfigReader.js +1 -0
  57. package/dist/contracts/IGitIndexLock.d.ts +44 -0
  58. package/dist/contracts/IGitIndexLock.js +1 -0
  59. package/dist/contracts/IGitReader.d.ts +81 -0
  60. package/dist/contracts/IGitReader.js +1 -0
  61. package/dist/contracts/IGitRunner.d.ts +49 -0
  62. package/dist/contracts/IGitRunner.js +1 -0
  63. package/dist/contracts/IGitWriter.d.ts +39 -0
  64. package/dist/contracts/IGitWriter.js +1 -0
  65. package/dist/contracts/IHaltStore.d.ts +14 -0
  66. package/dist/contracts/IHaltStore.js +1 -0
  67. package/dist/contracts/IHookInstaller.d.ts +14 -0
  68. package/dist/contracts/IHookInstaller.js +1 -0
  69. package/dist/contracts/IInstanceLock.d.ts +26 -0
  70. package/dist/contracts/IInstanceLock.js +1 -0
  71. package/dist/contracts/ILogger.d.ts +11 -0
  72. package/dist/contracts/ILogger.js +1 -0
  73. package/dist/contracts/IOperationJournal.d.ts +12 -0
  74. package/dist/contracts/IOperationJournal.js +1 -0
  75. package/dist/contracts/IPathQuarantine.d.ts +67 -0
  76. package/dist/contracts/IPathQuarantine.js +1 -0
  77. package/dist/contracts/IProcessHost.d.ts +11 -0
  78. package/dist/contracts/IProcessHost.js +1 -0
  79. package/dist/contracts/IRepositoryLocator.d.ts +32 -0
  80. package/dist/contracts/IRepositoryLocator.js +1 -0
  81. package/dist/contracts/IStateRecorder.d.ts +68 -0
  82. package/dist/contracts/IStateRecorder.js +1 -0
  83. package/dist/contracts/IStateStore.d.ts +88 -0
  84. package/dist/contracts/IStateStore.js +1 -0
  85. package/dist/contracts/ISyncCycle.d.ts +23 -0
  86. package/dist/contracts/ISyncCycle.js +1 -0
  87. package/dist/contracts/ISyncCycleRunner.d.ts +4 -0
  88. package/dist/contracts/ISyncCycleRunner.js +1 -0
  89. package/dist/contracts/ISyncScheduler.d.ts +6 -0
  90. package/dist/contracts/ISyncScheduler.js +1 -0
  91. package/dist/contracts/ITextSink.d.ts +3 -0
  92. package/dist/contracts/ITextSink.js +1 -0
  93. package/dist/contracts/IWatcher.d.ts +9 -0
  94. package/dist/contracts/IWatcher.js +1 -0
  95. package/dist/contracts/config.d.ts +26 -0
  96. package/dist/contracts/config.js +1 -0
  97. package/dist/contracts/errors.d.ts +39 -0
  98. package/dist/contracts/errors.js +64 -0
  99. package/dist/contracts/exitCode.d.ts +10 -0
  100. package/dist/contracts/exitCode.js +17 -0
  101. package/dist/contracts/gitTypes.d.ts +113 -0
  102. package/dist/contracts/gitTypes.js +1 -0
  103. package/dist/contracts/library.d.ts +35 -0
  104. package/dist/contracts/library.js +1 -0
  105. package/dist/contracts/logCodes.d.ts +1 -0
  106. package/dist/contracts/logCodes.js +1 -0
  107. package/dist/contracts/runtime.d.ts +16 -0
  108. package/dist/contracts/runtime.js +1 -0
  109. package/dist/contracts/syncTypes.d.ts +75 -0
  110. package/dist/contracts/syncTypes.js +6 -0
  111. package/dist/core/commit/commitMemory.d.ts +3 -0
  112. package/dist/core/commit/commitMemory.js +9 -0
  113. package/dist/core/commit/commitMessage.d.ts +6 -0
  114. package/dist/core/commit/commitMessage.js +12 -0
  115. package/dist/core/config/parseConfig.d.ts +8 -0
  116. package/dist/core/config/parseConfig.js +176 -0
  117. package/dist/core/content/contentRules.d.ts +11 -0
  118. package/dist/core/content/contentRules.js +50 -0
  119. package/dist/core/errors/errorMessage.d.ts +6 -0
  120. package/dist/core/errors/errorMessage.js +14 -0
  121. package/dist/core/errors/withCycleId.d.ts +7 -0
  122. package/dist/core/errors/withCycleId.js +38 -0
  123. package/dist/core/git/enmetuRefs.d.ts +9 -0
  124. package/dist/core/git/enmetuRefs.js +22 -0
  125. package/dist/core/git/porcelain.d.ts +52 -0
  126. package/dist/core/git/porcelain.js +219 -0
  127. package/dist/core/git/rebaseOwnership.d.ts +28 -0
  128. package/dist/core/git/rebaseOwnership.js +29 -0
  129. package/dist/core/git/stoppedRebaseWrites.d.ts +10 -0
  130. package/dist/core/git/stoppedRebaseWrites.js +22 -0
  131. package/dist/core/halt/resumeHint.d.ts +6 -0
  132. package/dist/core/halt/resumeHint.js +9 -0
  133. package/dist/core/log/formatLogLine.d.ts +3 -0
  134. package/dist/core/log/formatLogLine.js +11 -0
  135. package/dist/core/quarantine/keptBlobMessage.d.ts +5 -0
  136. package/dist/core/quarantine/keptBlobMessage.js +8 -0
  137. package/dist/core/quarantine/restoreWarnings.d.ts +16 -0
  138. package/dist/core/quarantine/restoreWarnings.js +36 -0
  139. package/dist/core/schedule/schedulerStep.d.ts +63 -0
  140. package/dist/core/schedule/schedulerStep.js +144 -0
  141. package/dist/core/state/stateGuards.d.ts +21 -0
  142. package/dist/core/state/stateGuards.js +295 -0
  143. package/dist/core/state/stateLiveness.d.ts +10 -0
  144. package/dist/core/state/stateLiveness.js +21 -0
  145. package/dist/core/state/stateRecordStep.d.ts +28 -0
  146. package/dist/core/state/stateRecordStep.js +203 -0
  147. package/dist/core/status/conflictGuide.d.ts +30 -0
  148. package/dist/core/status/conflictGuide.js +86 -0
  149. package/dist/core/status/orphanedCommits.d.ts +15 -0
  150. package/dist/core/status/orphanedCommits.js +24 -0
  151. package/dist/core/status/statusHeadline.d.ts +46 -0
  152. package/dist/core/status/statusHeadline.js +130 -0
  153. package/dist/core/watch/watchFilter.d.ts +2 -0
  154. package/dist/core/watch/watchFilter.js +34 -0
  155. package/dist/daemon/daemon.d.ts +55 -0
  156. package/dist/daemon/daemon.js +277 -0
  157. package/dist/enmetuInstances.d.ts +65 -0
  158. package/dist/enmetuInstances.js +332 -0
  159. package/dist/halt/haltGuardGitWriter.d.ts +23 -0
  160. package/dist/halt/haltGuardGitWriter.js +61 -0
  161. package/dist/halt/haltStore.d.ts +13 -0
  162. package/dist/halt/haltStore.js +49 -0
  163. package/dist/halt/recordHalt.d.ts +19 -0
  164. package/dist/halt/recordHalt.js +20 -0
  165. package/dist/hooks/hookScript.d.ts +22 -0
  166. package/dist/hooks/hookScript.js +52 -0
  167. package/dist/hooks/nullHookInstaller.d.ts +5 -0
  168. package/dist/hooks/nullHookInstaller.js +6 -0
  169. package/dist/hooks/preCommitCheckController.d.ts +23 -0
  170. package/dist/hooks/preCommitCheckController.js +22 -0
  171. package/dist/hooks/preCommitHookInstaller.d.ts +32 -0
  172. package/dist/hooks/preCommitHookInstaller.js +123 -0
  173. package/dist/index.d.ts +18 -0
  174. package/dist/index.js +38 -0
  175. package/dist/library/enmetuLibrary.d.ts +16 -0
  176. package/dist/library/enmetuLibrary.js +62 -0
  177. package/dist/library/readStateController.d.ts +28 -0
  178. package/dist/library/readStateController.js +32 -0
  179. package/dist/library/requestedStopProcessHost.d.ts +28 -0
  180. package/dist/library/requestedStopProcessHost.js +58 -0
  181. package/dist/lock/instanceLock.d.ts +26 -0
  182. package/dist/lock/instanceLock.js +125 -0
  183. package/dist/preflight/repositoryPreflight.d.ts +20 -0
  184. package/dist/preflight/repositoryPreflight.js +42 -0
  185. package/dist/quarantine/pathQuarantine.d.ts +113 -0
  186. package/dist/quarantine/pathQuarantine.js +504 -0
  187. package/dist/recovery/enmetuRebaseAbort.d.ts +108 -0
  188. package/dist/recovery/enmetuRebaseAbort.js +302 -0
  189. package/dist/recovery/operationJournal.d.ts +13 -0
  190. package/dist/recovery/operationJournal.js +37 -0
  191. package/dist/recovery/recoveryController.d.ts +48 -0
  192. package/dist/recovery/recoveryController.js +112 -0
  193. package/dist/state/stateFileStore.d.ts +18 -0
  194. package/dist/state/stateFileStore.js +73 -0
  195. package/dist/state/stateRecorder.d.ts +26 -0
  196. package/dist/state/stateRecorder.js +69 -0
  197. package/dist/state/stateRecordingSyncCycle.d.ts +24 -0
  198. package/dist/state/stateRecordingSyncCycle.js +71 -0
  199. package/dist/sync/hookAbortingSyncScheduler.d.ts +16 -0
  200. package/dist/sync/hookAbortingSyncScheduler.js +27 -0
  201. package/dist/sync/integrateUpstreamController.d.ts +95 -0
  202. package/dist/sync/integrateUpstreamController.js +353 -0
  203. package/dist/sync/syncCycleController.d.ts +71 -0
  204. package/dist/sync/syncCycleController.js +345 -0
  205. package/dist/sync/syncCycleRunner.d.ts +24 -0
  206. package/dist/sync/syncCycleRunner.js +169 -0
  207. package/dist/sync/syncScheduler.d.ts +32 -0
  208. package/dist/sync/syncScheduler.js +97 -0
  209. package/package.json +42 -4
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Yves Van Goethem, fluado UG (haftungsbeschränkt)
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,210 @@
1
- # Temporary Holding Version
1
+ <p align="center">
2
+ <img src="logo.svg" width="80" height="80" alt="enmetu">
3
+ </p>
2
4
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
5
+ <h1 align="center">enmetu</h1>
6
+
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.
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
+
21
+ ## Prerequisites
22
+
23
+ - Node 24+ or Bun 1.3.6+
24
+ - git 2.38+
25
+ - A checked-out branch with an upstream, at least one commit, and a committer identity
26
+
27
+ ## Install
28
+
29
+ ### Node
30
+
31
+ Inside a repository:
32
+
33
+ ```sh
34
+ npx @fluado/enmetu run
35
+ ```
36
+
37
+ As a project dependency: `npm install -D @fluado/enmetu`, then `npx enmetu run`.
38
+
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`.
54
+
55
+ ## Commands
56
+
57
+ ```
58
+ enmetu run [--repo <path>] run in the foreground
59
+ enmetu status [--repo <path>] show whether your work is committed and pushed
60
+ enmetu resume [--repo <path>] [--accept-upstream] clear a halt
61
+ enmetu hook pre-commit run the pre-commit check
62
+ enmetu --help | --version
63
+ ```
64
+
65
+ | Exit code | Meaning |
66
+ |---|---|
67
+ | 0 | success |
68
+ | 1 | error, or the hook refused the commit |
69
+ | 2 | bad arguments or invalid config |
70
+ | 3 | halted, or resume refused |
71
+ | 4 | another enmetu runs on this worktree |
72
+ | 5 | repository not usable: bare, detached HEAD, no upstream, no commit, git too old, operation in progress |
73
+ | 130 | second Ctrl-C |
74
+
75
+ ## Library
76
+
77
+ ```js
78
+ import { startEnmetu, readEnmetuState } from '@fluado/enmetu'
79
+
80
+ const enmetu = startEnmetu({ repo: '.' })
81
+ const { liveness, record } = await readEnmetuState('.')
82
+ const exitCode = await enmetu.stop()
83
+ ```
84
+
85
+ ## Configuration
86
+
87
+ `enmetu.config.json` at the repository root, optional:
88
+
89
+ | Key | Default | Meaning |
90
+ |---|---|---|
91
+ | `quietMs` | `5000` | wait after the last change before committing |
92
+ | `maxDelayMs` | `60000` | maximum delay while changes keep coming |
93
+ | `pollMs` | `5000` | remote check interval |
94
+ | `commitSubject` | `"enmetu: local changes"` | commit message |
95
+ | `installHook` | `true` | install the pre-commit hook |
96
+ | `skipJsonCheck` | `[]` | files exempt from the JSON check, such as `tsconfig.json` |
97
+ | `gitTimeoutMs` | `120000` | timeout per git command |
98
+ | `stateHistory` | `20` | state changes kept in the state file (0 to 100) |
99
+ | `partialStagingWaitMs` | `300000` | with a partial staging: commit everything after this long; `0` at once |
100
+ | `commitHooks` | `true` | run your commit hooks on enmetu's commits |
101
+ | `hookTimeoutMs` | `600000` | timeout per hook |
102
+
103
+ Unknown settings are logged and ignored.
104
+
105
+ ## Status
106
+
107
+ ```
108
+ safe: pushed 3f2a9c1, 0 unpushed
109
+ repository /work/notes
110
+ instance running, pid 4242 on devbox.example.com since 2026-11-01T12:00:00.000Z
111
+ state synced since 2026-11-01T12:00:05.000Z; last cycle c3 synced at 2026-11-01T12:01:10.000Z
112
+ halt none
113
+ recovery none
114
+ branch refs/heads/main -> origin refs/heads/main
115
+ divergence 0 ahead, 0 behind (against the last fetched upstream)
116
+ uncommitted none
117
+ ```
118
+
119
+ | First word | Meaning |
120
+ |---|---|
121
+ | `safe` | committed and pushed |
122
+ | `pending` | uncommitted changes |
123
+ | `syncing` | unpushed commits |
124
+ | `waiting` | partial staging, or a hook rejected the commit |
125
+ | `attention` | conflict, push failure, orphaned commit, or failed cycle |
126
+ | `paused` | your git operation in progress, detached HEAD, or no upstream |
127
+ | `offline` | remote unreachable |
128
+ | `busy` | another git command holds the repository |
129
+ | `halted` | fix the cause, then `enmetu resume` |
130
+ | `not running` | stopped, crashed, or never started |
131
+
132
+ ## Logs
133
+
134
+ stderr, one line per event:
135
+
136
+ ```
137
+ 2026-11-01T12:00:05.120Z INFO c2 COMMITTED local changes
138
+ 2026-11-01T12:00:05.122Z WARN c2 EXCLUDED state.json: CONFLICT_MARKERS (opening and closing marker lines)
139
+ 2026-11-01T12:01:10.341Z WARN c15 UNPUSHED 2 local commits not on origin refs/heads/main
140
+ ```
141
+
142
+ | Code | Meaning |
143
+ |---|---|
144
+ | `EXCLUDED` | file not committed: conflict markers or invalid JSON |
145
+ | `CONFLICT`, `UNPUSHED` | local commits not pushed |
146
+ | `WAITING`, `PAUSED`, `OFFLINE` | sync on hold |
147
+ | `HALTED` | see `enmetu status` |
148
+ | `COMMIT_ORPHANED`, `STAGED_RESET`, `STAGED_REVERT`, `QUARANTINE_KEPT` | see Known limitations |
149
+
150
+ ## State file
151
+
152
+ `.git/enmetu/state.json`, one line of JSON:
153
+
154
+ | Field | Meaning |
155
+ |---|---|
156
+ | `state` | `starting`, `synced`, `waiting`, `conflict`, `paused`, `busy`, `offline`, `push-failed`, `halted`, `stopped` |
157
+ | `reason` | code for `paused`, `waiting` and `halted` |
158
+ | `message` | summary |
159
+ | `since` | time of the last state change |
160
+ | `pushed` | last commit verified on the upstream |
161
+ | `unpushed` | local commits not on the upstream |
162
+ | `lastCycle` | id, outcome and time of the last cycle |
163
+ | `conflict` | conflicting commits |
164
+ | `history` | recent state changes |
165
+ | `orphaned` | commits left on no branch |
166
+ | `instance`, `version` | writer process, format version |
167
+
168
+ Stale after a crash; use `enmetu status` to check whether enmetu runs.
169
+
170
+ ## Pre-commit
171
+
172
+ Installs `.git/hooks/pre-commit`, which refuses commits with conflict markers or invalid JSON. Bypass with `git commit --no-verify`.
173
+
174
+ Skipped when `core.hooksPath` is set or a `pre-commit` hook exists. Add this to your hook instead:
175
+
176
+ ```sh
177
+ if command -v enmetu >/dev/null 2>&1; then enmetu hook pre-commit || exit 1; else echo "enmetu: pre-commit check skipped (enmetu not on PATH)" >&2; fi
178
+ ```
179
+
180
+ ## Known limitations
181
+
182
+ - One repository per process. No system service, no notifications.
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.
184
+ - `commit-msg` and `prepare-commit-msg` hooks don't run on enmetu's commits.
185
+ - Under Bun without file-watching permission, enmetu commits on its poll interval.
186
+ - A save during enmetu's pull can be overwritten by git without notice.
187
+
188
+ Overlapping git commands:
189
+
190
+ | Symptom | Cause | Fix |
191
+ |---|---|---|
192
+ | `cannot lock ref 'HEAD'` | raced enmetu's commit | after `INDEX_REPAIRED` in the log: `git reset --merge`, retry. If `not uptodate`: `git merge --quit`, `git reset` |
193
+ | `STAGED_RESET` | staged during enmetu's rebase | command in the log line |
194
+ | `QUARANTINE_KEPT` | your edit collided with a version enmetu held aside | command in the log line |
195
+ | `COMMIT_ORPHANED` | committed during enmetu's pull | `git cherry-pick -x` command in `enmetu status` |
196
+ | `STAGED_REVERT` | a refused `git merge` restored old content | `git restore` command in the log line |
197
+ | `could not write index` on a refused merge | git stashed your edits | `git fsck --no-reflogs --unreachable`, then `git stash apply <id>` |
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 |
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
+
208
+ ## Licence
209
+
210
+ MIT, Copyright (c) 2026 Yves Van Goethem, fluado UG (haftungsbeschränkt). See `LICENSE`.
@@ -0,0 +1,6 @@
1
+ import type { IClock, TimerHandle } from '../../contracts/IClock.js';
2
+ /** Adapter: Date.now and setTimeout to IClock; the only file touching them. */
3
+ export declare class SystemClockAdapter implements IClock {
4
+ now(): number;
5
+ after(ms: number, callback: () => void): TimerHandle;
6
+ }
@@ -0,0 +1,12 @@
1
+ /** Adapter: Date.now and setTimeout to IClock; the only file touching them. */
2
+ export class SystemClockAdapter {
3
+ now() {
4
+ return Date.now();
5
+ }
6
+ after(ms, callback) {
7
+ // Not unref'd: in poll-only mode (watcher failed) the pending timer is the
8
+ // only handle that keeps the daemon's event loop alive.
9
+ const timer = setTimeout(callback, ms);
10
+ return { cancel: () => clearTimeout(timer) };
11
+ }
12
+ }
@@ -0,0 +1,18 @@
1
+ import type { FileFacts, IFileSystem, WriteOptions } from '../../contracts/IFileSystem.js';
2
+ /** Adapter: node:fs/promises to IFileSystem; the only file importing it. */
3
+ export declare class NodeFileSystemAdapter implements IFileSystem {
4
+ readText(path: string): Promise<string | null>;
5
+ writeAtomic(path: string, content: string | Buffer, options: WriteOptions): Promise<void>;
6
+ createExclusive(path: string, content: string): Promise<boolean>;
7
+ rename(from: string, to: string): Promise<boolean>;
8
+ remove(path: string): Promise<void>;
9
+ facts(path: string): Promise<FileFacts | null>;
10
+ /** copyFile (COPYFILE_FICLONE where offered), then the source's time rounded down to the second; false when the source is absent (design 12.7). */
11
+ copy(from: string, to: string): Promise<boolean>;
12
+ /** stat with bigint times: `${dev}:${ino}:${size}:${mtimeNs}:${ctimeNs}`; null when absent (design 12.7). */
13
+ identity(path: string): Promise<string | null>;
14
+ /** link(2); false when the source is absent; EEXIST propagates (design 12.7). */
15
+ link(existing: string, target: string): Promise<boolean>;
16
+ /** stat's device of both paths, compared (design 13.11, Decision 117). */
17
+ sameFileSystem(left: string, right: string): Promise<boolean>;
18
+ }
@@ -0,0 +1,230 @@
1
+ import { randomBytes } from 'node:crypto';
2
+ import { constants, copyFile, link, lstat, mkdir, open, readFile, rename, stat, unlink, utimes, } from 'node:fs/promises';
3
+ import { basename, dirname, join } from 'node:path';
4
+ // The umask applies to these, the same way it does when git checks a file out.
5
+ const FILE_MODE = 0o666;
6
+ const EXECUTABLE_MODE = 0o777;
7
+ /** Adapter: node:fs/promises to IFileSystem; the only file importing it. */
8
+ export class NodeFileSystemAdapter {
9
+ async readText(path) {
10
+ try {
11
+ return await readFile(path, 'utf8');
12
+ }
13
+ catch (error) {
14
+ if (isAbsent(error)) {
15
+ return null;
16
+ }
17
+ throw error;
18
+ }
19
+ }
20
+ async writeAtomic(path, content, options) {
21
+ const targetDir = dirname(path);
22
+ await mkdir(targetDir, { recursive: true });
23
+ await mkdir(options.tempDir, { recursive: true });
24
+ const mode = options.executable ? EXECUTABLE_MODE : FILE_MODE;
25
+ try {
26
+ await writeThenRename(options.tempDir, path, content, mode);
27
+ }
28
+ catch (error) {
29
+ // rename(2) cannot cross file systems; next to the target it always can.
30
+ if (errorCode(error) !== 'EXDEV') {
31
+ throw error;
32
+ }
33
+ await writeThenRename(targetDir, path, content, mode);
34
+ }
35
+ }
36
+ async createExclusive(path, content) {
37
+ const targetDir = dirname(path);
38
+ await mkdir(targetDir, { recursive: true });
39
+ // link(2) publishes the complete file in one step and fails on an existing
40
+ // target, so a reader never sees a half-written lock; it needs the temp
41
+ // file on the target's file system.
42
+ const temp = await writeTempFile(targetDir, path, content, FILE_MODE);
43
+ try {
44
+ await link(temp, path);
45
+ return true;
46
+ }
47
+ catch (error) {
48
+ if (errorCode(error) === 'EEXIST') {
49
+ return false;
50
+ }
51
+ throw error;
52
+ }
53
+ finally {
54
+ await removeIfPresent(temp);
55
+ }
56
+ }
57
+ async rename(from, to) {
58
+ try {
59
+ await rename(from, to);
60
+ return true;
61
+ }
62
+ catch (error) {
63
+ // ENOENT also means a missing directory of the target; only an absent
64
+ // source is the documented false.
65
+ if (!isAbsent(error)) {
66
+ throw error;
67
+ }
68
+ if ((await this.facts(from)) === null) {
69
+ return false;
70
+ }
71
+ // The source exists, so the target's directory is missing (the
72
+ // quarantine's temp directory before its first write); created only
73
+ // then, so an absent source creates nothing.
74
+ await mkdir(dirname(to), { recursive: true });
75
+ }
76
+ try {
77
+ await rename(from, to);
78
+ return true;
79
+ }
80
+ catch (error) {
81
+ if (isAbsent(error) && (await this.facts(from)) === null) {
82
+ return false;
83
+ }
84
+ throw error;
85
+ }
86
+ }
87
+ async remove(path) {
88
+ await removeIfPresent(path);
89
+ }
90
+ async facts(path) {
91
+ try {
92
+ const stats = await lstat(path);
93
+ if (stats.isSymbolicLink()) {
94
+ return { kind: 'symlink', executable: false };
95
+ }
96
+ if (stats.isFile()) {
97
+ // git decides a file is executable by the owner's execute bit alone.
98
+ return { kind: 'file', executable: (stats.mode & 0o100) !== 0 };
99
+ }
100
+ if (stats.isDirectory()) {
101
+ return { kind: 'directory', executable: false };
102
+ }
103
+ return { kind: 'other', executable: false };
104
+ }
105
+ catch (error) {
106
+ if (isAbsent(error)) {
107
+ return null;
108
+ }
109
+ throw error;
110
+ }
111
+ }
112
+ /** copyFile (COPYFILE_FICLONE where offered), then the source's time rounded down to the second; false when the source is absent (design 12.7). */
113
+ async copy(from, to) {
114
+ // Read before the copy: a source replaced in between gives newer bytes an
115
+ // older time, which only makes git re-check more entries, never fewer.
116
+ let source;
117
+ try {
118
+ source = await stat(from);
119
+ }
120
+ catch (error) {
121
+ if (isAbsent(error)) {
122
+ return false;
123
+ }
124
+ throw error;
125
+ }
126
+ try {
127
+ await copyFile(from, to, constants.COPYFILE_FICLONE);
128
+ }
129
+ catch (error) {
130
+ if (!isAbsent(error)) {
131
+ throw error;
132
+ }
133
+ if ((await this.facts(from)) === null) {
134
+ return false;
135
+ }
136
+ // The source exists, so the target's directory is missing (the state
137
+ // directory before the first write); created only then, so an absent
138
+ // source creates nothing.
139
+ await mkdir(dirname(to), { recursive: true });
140
+ await copyFile(from, to, constants.COPYFILE_FICLONE);
141
+ }
142
+ await utimes(to, source.atime, Math.floor(source.mtimeMs / 1000));
143
+ return true;
144
+ }
145
+ /** stat with bigint times: `${dev}:${ino}:${size}:${mtimeNs}:${ctimeNs}`; null when absent (design 12.7). */
146
+ async identity(path) {
147
+ try {
148
+ const stats = await stat(path, { bigint: true });
149
+ return `${stats.dev}:${stats.ino}:${stats.size}:${stats.mtimeNs}:${stats.ctimeNs}`;
150
+ }
151
+ catch (error) {
152
+ if (isAbsent(error)) {
153
+ return null;
154
+ }
155
+ throw error;
156
+ }
157
+ }
158
+ /** link(2); false when the source is absent; EEXIST propagates (design 12.7). */
159
+ async link(existing, target) {
160
+ try {
161
+ await link(existing, target);
162
+ return true;
163
+ }
164
+ catch (error) {
165
+ if (isAbsent(error) && (await this.facts(existing)) === null) {
166
+ return false;
167
+ }
168
+ throw error;
169
+ }
170
+ }
171
+ /** stat's device of both paths, compared (design 13.11, Decision 117). */
172
+ async sameFileSystem(left, right) {
173
+ const [a, b] = await Promise.all([
174
+ stat(left, { bigint: true }),
175
+ stat(right, { bigint: true }),
176
+ ]);
177
+ return a.dev === b.dev;
178
+ }
179
+ }
180
+ async function writeThenRename(tempDir, path, content, mode) {
181
+ const temp = await writeTempFile(tempDir, path, content, mode);
182
+ try {
183
+ await rename(temp, path);
184
+ }
185
+ catch (error) {
186
+ await removeIfPresent(temp);
187
+ throw error;
188
+ }
189
+ }
190
+ /** Writes and flushes a uniquely named temp file; removes it again on failure. */
191
+ async function writeTempFile(dir, path, content, mode) {
192
+ const temp = join(dir, `.${basename(path)}.${randomBytes(6).toString('hex')}.tmp`);
193
+ const handle = await open(temp, 'wx', mode);
194
+ try {
195
+ await handle.writeFile(content);
196
+ // Without the flush a crash after the rename can leave the target empty
197
+ // on file systems that persist the rename before the data.
198
+ await handle.sync();
199
+ }
200
+ catch (error) {
201
+ await handle.close();
202
+ await removeIfPresent(temp);
203
+ throw error;
204
+ }
205
+ await handle.close();
206
+ return temp;
207
+ }
208
+ async function removeIfPresent(path) {
209
+ try {
210
+ await unlink(path);
211
+ }
212
+ catch (error) {
213
+ if (!isAbsent(error)) {
214
+ throw error;
215
+ }
216
+ }
217
+ }
218
+ // ENOTDIR: a component of the path is a file, so the path cannot exist.
219
+ function isAbsent(error) {
220
+ const code = errorCode(error);
221
+ return code === 'ENOENT' || code === 'ENOTDIR';
222
+ }
223
+ // Not instanceof Error: an error from Node's core can come from another realm
224
+ // (Jest's test context), where instanceof is false.
225
+ function errorCode(error) {
226
+ if (typeof error === 'object' && error !== null && 'code' in error) {
227
+ return typeof error.code === 'string' ? error.code : undefined;
228
+ }
229
+ return undefined;
230
+ }
@@ -0,0 +1,148 @@
1
+ import type { Divergence, FetchResult, HeadMove, PushResult, RebaseResult, RebaseStart, RemoteHead, RepositoryState, StagedFile, StageResult, StatusEntry, Upstream } from '../../contracts/gitTypes.js';
2
+ import type { IFileSystem } from '../../contracts/IFileSystem.js';
3
+ import type { CommitHookName, CommitTreeInput, HookRun, IGitCommitter } from '../../contracts/IGitCommitter.js';
4
+ import type { IGitReader } from '../../contracts/IGitReader.js';
5
+ import type { IGitRunner } from '../../contracts/IGitRunner.js';
6
+ import type { IGitWriter } from '../../contracts/IGitWriter.js';
7
+ /** At most this much blob content is requested from one `git cat-file --batch` call. */
8
+ export declare const GIT_BATCH_CHUNK_BYTES = 67108864;
9
+ /** A hook's stdout and stderr beyond this are dropped, never a failure (design 12.7, Decision 83). */
10
+ export declare const HOOK_OUTPUT_CAP_BYTES = 65536;
11
+ /**
12
+ * Facade: typed questions and commands over the git CLI, with exactly the
13
+ * argv and interpretations of design section 1.3. gitDir locates the state
14
+ * files repositoryState and the rebase readers check.
15
+ */
16
+ export declare class GitFacade implements IGitReader, IGitWriter, IGitCommitter {
17
+ private readonly runner;
18
+ private readonly fs;
19
+ private readonly gitDir;
20
+ /** A repository hook's own budget (hookTimeoutMs); only runHook uses it (design 12.7, Decision 83). */
21
+ private readonly hookTimeoutMs?;
22
+ /** Aborted by a stop request: a running hook is ended, which is a rejection (Decision 92); only runHook uses it. */
23
+ private readonly hookAbort?;
24
+ constructor(runner: IGitRunner, fs: IFileSystem, gitDir: string,
25
+ /** A repository hook's own budget (hookTimeoutMs); only runHook uses it (design 12.7, Decision 83). */
26
+ hookTimeoutMs?: number | undefined,
27
+ /** Aborted by a stop request: a running hook is ended, which is a rejection (Decision 92); only runHook uses it. */
28
+ hookAbort?: AbortSignal | undefined);
29
+ repositoryState(): Promise<RepositoryState>;
30
+ headCommit(): Promise<string | null>;
31
+ hasCommitterIdentity(): Promise<boolean>;
32
+ upstream(branchRef: string): Promise<Upstream | null>;
33
+ status(paths?: readonly string[] | undefined): Promise<readonly StatusEntry[]>;
34
+ stagedFiles(maxBytes: number): AsyncIterable<StagedFile>;
35
+ hasStagedChanges(): Promise<boolean>;
36
+ /** git diff --cached --name-only -z --no-renames --ignore-submodules=dirty (Decision 98). */
37
+ stagedPaths(): Promise<readonly string[]>;
38
+ remoteHead(remote: string, remoteRef: string): Promise<RemoteHead>;
39
+ resolveCommit(ref: string): Promise<string | null>;
40
+ isAncestor(ancestor: string, descendant: string): Promise<boolean>;
41
+ divergence(upstreamSha: string): Promise<Divergence>;
42
+ hasUnmergedPaths(): Promise<boolean>;
43
+ rebaseOrigHead(): Promise<string | null>;
44
+ rebaseOnto(): Promise<string | null>;
45
+ /**
46
+ * The branch a rebase in progress returns to: rebase-merge/head-name, else
47
+ * rebase-apply/head-name (design 14.4). git writes `detached HEAD` there for
48
+ * a rebase that started without a branch.
49
+ */
50
+ rebaseHeadName(): Promise<string | null>;
51
+ /**
52
+ * git reflog exists HEAD, then git log -g -1 --format=%gs
53
+ * --grep-reflog=" (start): " HEAD (design 13.2, Decision 104). Without a
54
+ * reflog `git log -g` prints nothing with exit 0, exactly like a reflog
55
+ * without a start entry, so the first call keeps the two apart.
56
+ */
57
+ rebaseStart(): Promise<RebaseStart>;
58
+ /**
59
+ * git log -g -n HEAD_MOVE_SCAN --format="%H %gs" HEAD, newest first (design
60
+ * 14.14). Without a reflog git prints nothing with exit 0, which is also
61
+ * what a reflog holding no entry of the rebase's yields: null either way.
62
+ */
63
+ headMovedSince(reflogAction: string): Promise<HeadMove | null>;
64
+ commitSubject(commit: string): Promise<string>;
65
+ /**
66
+ * Two reads, the cheap one first (design 14.15). --since bounds the log's
67
+ * walk to commits made after enmetu named the orphan (in whole seconds, a
68
+ * second early, since git's committer dates have no fraction); a time that
69
+ * does not parse walks every branch.
70
+ */
71
+ onLocalBranch(commit: string, since: string): Promise<boolean>;
72
+ readBlob(blobId: string): Promise<Buffer>;
73
+ hashFile(path: string): Promise<string>;
74
+ worktreeBlobId(path: string): Promise<string>;
75
+ entryBlobId(path: string, commit: string | null): Promise<string | null>;
76
+ /** git merge-file -p: exit 0 is clean; a conflict count or an error (binary input) is null. */
77
+ mergeFile(current: string, base: string, other: string): Promise<Buffer | null>;
78
+ /** git merge-base <left> <right>: exit 1 is null (design 12.3). */
79
+ mergeBase(left: string, right: string): Promise<string | null>;
80
+ /** git diff --name-only -z --no-renames <from> <to> (design 12.3). */
81
+ changedPaths(from: string, to: string): Promise<readonly string[]>;
82
+ /**
83
+ * git -c core.hooksPath=/dev/null read-tree --reset <commit> (design 12.7):
84
+ * -m's one-tree merge, which keeps the stat data of matching entries, except
85
+ * that unmerged entries a copied index carries (a conflicted stash pop, a
86
+ * person's merge) are discarded instead of refused (measured with git 2.52:
87
+ * -m exits 128 with "You need to resolve your current index first").
88
+ */
89
+ seedFrom(commit: string): Promise<void>;
90
+ /** git -c core.hooksPath=/dev/null write-tree (design 12.7). */
91
+ writeTree(): Promise<string>;
92
+ /** git commit-tree <tree> -p <parent> -S|--no-gpg-sign -F - (design 12.7). */
93
+ commitTree(input: CommitTreeInput): Promise<string>;
94
+ hookExists(name: CommitHookName): Promise<boolean>;
95
+ /**
96
+ * git hook run --ignore-missing <name> [-- <args>] with the runner options
97
+ * { timeoutMs: hookTimeoutMs, outputCapBytes: HOOK_OUTPUT_CAP_BYTES }
98
+ * (design 12.7, Decision 83): exit 0 passed; any other exit rejected with
99
+ * stderr; GIT_TIMEOUT rejected with 'timed out after <n> ms'; GIT_FAILED
100
+ * without an exit code (ended by a signal) rejected with the error's
101
+ * message. Throws only GIT_NOT_FOUND.
102
+ */
103
+ runHook(name: CommitHookName, args: readonly string[]): Promise<HookRun>;
104
+ configValue(key: string): Promise<string | null>;
105
+ stageAll(): Promise<StageResult>;
106
+ unstage(paths: readonly string[]): Promise<void>;
107
+ fetch(remote: string, remoteRef: string, intoRef: string): Promise<FetchResult>;
108
+ updateRef(ref: string, newSha: string, oldSha: string | null): Promise<void>;
109
+ /**
110
+ * reflogAction is the attempt's token: git writes it into HEAD's reflog
111
+ * with the rebase's checkout, which rebaseStart reads back (Decision 104).
112
+ */
113
+ rebase(ontoSha: string, reflogAction: string): Promise<RebaseResult>;
114
+ abortRebase(): Promise<void>;
115
+ /**
116
+ * git rebase --quit, then git switch -q --discard-changes <name>, both
117
+ * hookless (design 14.4, Decision 122). Neither exit code nor an index.lock
118
+ * message is interpreted: the caller verifies HEAD, the branch and the tip.
119
+ */
120
+ endRebaseAtBranch(branchRef: string): Promise<void>;
121
+ push(remote: string, sha: string, remoteRef: string): Promise<PushResult>;
122
+ storeBlob(path: string): Promise<string>;
123
+ restoreFromIndex(paths: readonly string[]): Promise<void>;
124
+ /** Runs git and turns an index.lock collision into GIT_INDEX_LOCKED; every other exit code is the caller's to read. */
125
+ private git;
126
+ /**
127
+ * Runs a command that talks to the remote. A timeout there is the network or
128
+ * a credential prompt (ssh connect, passphrase, pinentry), so it comes back as
129
+ * a value the cycle reports (OFFLINE, or PUSH_FAILED with UNPUSHED, D2),
130
+ * never an error that ends the cycle in CYCLE_FAILED.
131
+ */
132
+ private remoteGit;
133
+ /** Runs git and expects exit code 0. */
134
+ private gitOk;
135
+ private verifyCommit;
136
+ private exists;
137
+ private rebaseStateFile;
138
+ /** The staged files in diff order, with the size of each blob whose content will be read. */
139
+ private stagedBlobs;
140
+ /** Sizes in request order; the blob ids of a staged diff must all exist. */
141
+ private blobSizes;
142
+ /**
143
+ * Reads the readable blobs from `start` on, in diff order, until the next
144
+ * one would push the chunk past GIT_BATCH_CHUNK_BYTES; the first blob is
145
+ * always taken, so a blob larger than a chunk is read alone.
146
+ */
147
+ private readChunk;
148
+ }