@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
@@ -0,0 +1,277 @@
1
+ import { PreflightError } from '../contracts/errors.js';
2
+ import { EXIT } from '../contracts/exitCode.js';
3
+ import { errorMessage } from '../core/errors/errorMessage.js';
4
+ import { resumeHint } from '../core/halt/resumeHint.js';
5
+ const SECOND_SIGNAL_EXIT = 130;
6
+ /**
7
+ * A copy of the first stop signal arriving within this window is the same
8
+ * stop, not a second one (Decision 91): a forwarding parent's copy arrives
9
+ * within milliseconds, a person's deliberate second Ctrl-C takes longer.
10
+ */
11
+ export const DUPLICATE_SIGNAL_WINDOW_MS = 500;
12
+ /**
13
+ * Service: the daemon lifecycle of design 1.16, steps 1 to 10. Steps 2 to 10
14
+ * run inside a try/finally that releases the lock on every exit path,
15
+ * including a throw; the final line is logged after the release, so STOPPED
16
+ * means the lock is gone.
17
+ */
18
+ export class Daemon {
19
+ settings;
20
+ lock;
21
+ halt;
22
+ recovery;
23
+ preflight;
24
+ hookInstaller;
25
+ watcher;
26
+ scheduler;
27
+ processHost;
28
+ logger;
29
+ state;
30
+ clock;
31
+ constructor(settings, lock, halt, recovery, preflight, hookInstaller, watcher, scheduler, processHost, logger,
32
+ /** Lifecycle events for state.json, recorded while the lock is held (design 12.1). */
33
+ state,
34
+ /** Tells a copy of the first stop signal from a second one (Decision 91). */
35
+ clock) {
36
+ this.settings = settings;
37
+ this.lock = lock;
38
+ this.halt = halt;
39
+ this.recovery = recovery;
40
+ this.preflight = preflight;
41
+ this.hookInstaller = hookInstaller;
42
+ this.watcher = watcher;
43
+ this.scheduler = scheduler;
44
+ this.processHost = processHost;
45
+ this.logger = logger;
46
+ this.state = state;
47
+ this.clock = clock;
48
+ }
49
+ async run() {
50
+ // --- Step 1: one instance per worktree (D13) ---
51
+ const acquisition = await this.lock.acquire();
52
+ if (acquisition.kind !== 'acquired') {
53
+ // Nothing was acquired, so there is nothing to release.
54
+ this.logger.log(lockedEntry(acquisition));
55
+ return EXIT.LOCKED;
56
+ }
57
+ if (acquisition.tookOverStale) {
58
+ this.logger.log({
59
+ level: 'WARN',
60
+ code: 'LOCK_STALE_TAKEN',
61
+ message: 'took over the lock of an enmetu instance that is no longer running',
62
+ });
63
+ }
64
+ // state.json is written only between acquire and release: before, it
65
+ // would overwrite a live instance's record; after, it would race the
66
+ // next instance (design 12.1).
67
+ let finish;
68
+ try {
69
+ await this.recordStarted();
70
+ try {
71
+ finish = await this.runLocked();
72
+ }
73
+ catch (error) {
74
+ await this.state.record({
75
+ type: 'stopped',
76
+ message: `stopped by an unexpected error: ${errorMessage(error)}`,
77
+ });
78
+ throw error;
79
+ }
80
+ await this.state.record(finish.event);
81
+ }
82
+ finally {
83
+ await this.lock.release();
84
+ }
85
+ this.logger.log(finish.entry);
86
+ return finish.exitCode;
87
+ }
88
+ /** The holder is this process; its identity is what stateLiveness compares. */
89
+ async recordStarted() {
90
+ const holder = await this.lock.inspect();
91
+ // Not expected right after acquire; without an identity the record would
92
+ // name no instance, so status judges the run unrecorded instead.
93
+ if (!holder) {
94
+ return;
95
+ }
96
+ await this.state.record({
97
+ type: 'started',
98
+ instance: {
99
+ pid: holder.pid,
100
+ hostname: holder.hostname,
101
+ startedAt: holder.startedAt,
102
+ },
103
+ });
104
+ }
105
+ /** Steps 2 to 10, with the lock held. */
106
+ async runLocked() {
107
+ // --- Step 2: a persisted halt refuses the start (D3, Decision 12) ---
108
+ const record = await this.halt.read();
109
+ if (record !== null) {
110
+ return halted({ type: 'halted', code: record.code, message: record.message }, `${record.code}: ${record.message}. ${resumeHint(record.code)}`);
111
+ }
112
+ // --- Step 3: recovery from a crash before anything else touches git ---
113
+ const recovery = await this.recovery.recover();
114
+ if (recovery.kind === 'halted') {
115
+ return halted({
116
+ type: 'halted',
117
+ code: recovery.code,
118
+ message: `recovery halted with ${recovery.code}; enmetu status shows why`,
119
+ }, `recovery halted with ${recovery.code}; enmetu status shows why. ${resumeHint(recovery.code)}`);
120
+ }
121
+ // A recovered start needs no line here: recovery logged RECOVERED per action.
122
+ // --- Step 4: eligibility (Decision 28) ---
123
+ let upstream;
124
+ try {
125
+ upstream = await this.preflight.check();
126
+ }
127
+ catch (error) {
128
+ if (!(error instanceof PreflightError)) {
129
+ throw error;
130
+ }
131
+ return {
132
+ exitCode: EXIT.NOT_ELIGIBLE,
133
+ event: {
134
+ type: 'stopped',
135
+ message: `not eligible: ${error.code}: ${error.message}`,
136
+ },
137
+ entry: {
138
+ level: 'ERROR',
139
+ code: 'NOT_ELIGIBLE',
140
+ message: `${error.code}: ${error.message}`,
141
+ cycleId: error.cycleId,
142
+ },
143
+ };
144
+ }
145
+ // --- Step 5: the pre-commit hook; never stops the start ---
146
+ const hookEntry = hookLogEntry(await this.hookInstaller.ensure());
147
+ if (hookEntry !== null) {
148
+ this.logger.log(hookEntry);
149
+ }
150
+ // --- Step 6: STARTED ---
151
+ const { root, quietMs, pollMs } = this.settings;
152
+ this.logger.log({
153
+ level: 'INFO',
154
+ code: 'STARTED',
155
+ message: `${root} on ${upstream.branchRef}, upstream ${upstream.remote} ${upstream.remoteRef}, quiet ${quietMs} ms, poll ${pollMs} ms`,
156
+ });
157
+ // --- Step 7: the first signal stops after the running cycle, a second exits at once ---
158
+ let first = null;
159
+ const unsubscribe = this.processHost.onStopSignal((signal) => {
160
+ const at = this.clock.now();
161
+ if (first !== null) {
162
+ if (signal === first.signal &&
163
+ at - first.at < DUPLICATE_SIGNAL_WINDOW_MS) {
164
+ // The same signal twice in a moment is one stop delivered twice:
165
+ // a Ctrl-C or a group kill reaches enmetu directly and again
166
+ // through a parent that forwards it (npm exec, npx), Decision 91.
167
+ return;
168
+ }
169
+ // A crash-equivalent: the journal and the memento make it recoverable (Decision 29).
170
+ this.processHost.exitNow(SECOND_SIGNAL_EXIT);
171
+ return;
172
+ }
173
+ first = { signal, at };
174
+ this.logger.log({
175
+ level: 'INFO',
176
+ code: 'STOPPING',
177
+ message: `${signal} received; stopping after the running cycle (a second signal exits at once)`,
178
+ });
179
+ this.scheduler.stop();
180
+ });
181
+ try {
182
+ // --- Step 8: the watcher triggers cycles; losing it leaves the poll (Decision 21) ---
183
+ this.watcher.start({
184
+ // A closure, never the unbound method: the watcher calls it detached.
185
+ onChange: () => this.scheduler.notifyChange(),
186
+ onFailure: (detail) => {
187
+ this.logger.log({
188
+ level: 'WARN',
189
+ code: 'WATCHER_FAILED',
190
+ message: `${detail}; changes are now picked up by the poll every ${pollMs} ms`,
191
+ });
192
+ this.watcher.close();
193
+ },
194
+ });
195
+ // --- Step 9: cycles until stopped or halted ---
196
+ const end = await this.scheduler.run();
197
+ // --- Step 10: the final line follows the lock release in run() ---
198
+ if (end === 'halted') {
199
+ // The marker names the code, so this line gives the hint the next start
200
+ // and status give; it is gone only if a person already resumed.
201
+ const record = await this.halt.read();
202
+ const code = record === null ? '' : `${record.code}; `;
203
+ // No marker means a person already resumed: no halt stands, so the
204
+ // record says stopped rather than naming a code that no longer holds.
205
+ const event = record === null
206
+ ? {
207
+ type: 'stopped',
208
+ message: 'stopped; the halt was already cleared',
209
+ }
210
+ : { type: 'halted', code: record.code, message: record.message };
211
+ return halted(event, `${code}enmetu status shows why. ${resumeHint(record?.code ?? null)}`);
212
+ }
213
+ return {
214
+ exitCode: EXIT.OK,
215
+ event: { type: 'stopped', message: 'stopped' },
216
+ entry: { level: 'INFO', code: 'STOPPED', message: 'stopped' },
217
+ };
218
+ }
219
+ finally {
220
+ this.watcher.close();
221
+ unsubscribe();
222
+ }
223
+ }
224
+ }
225
+ function halted(event, message) {
226
+ return {
227
+ exitCode: EXIT.HALTED,
228
+ event,
229
+ entry: { level: 'ERROR', code: 'HALTED', message },
230
+ };
231
+ }
232
+ function lockedEntry(acquisition) {
233
+ if (acquisition.kind === 'held') {
234
+ const { pid, hostname, startedAt } = acquisition.holder;
235
+ return {
236
+ level: 'ERROR',
237
+ code: 'LOCKED',
238
+ message: `another enmetu runs on this worktree: pid ${pid} on ${hostname} since ${startedAt}`,
239
+ };
240
+ }
241
+ return {
242
+ level: 'ERROR',
243
+ code: 'LOCKED',
244
+ message: `the lock file ${acquisition.path} cannot be read; delete it if no enmetu runs on this worktree`,
245
+ };
246
+ }
247
+ function hookLogEntry(result) {
248
+ switch (result.kind) {
249
+ case 'installed':
250
+ return {
251
+ level: 'INFO',
252
+ code: 'HOOK_INSTALLED',
253
+ message: 'installed the pre-commit hook',
254
+ };
255
+ case 'updated':
256
+ return {
257
+ level: 'INFO',
258
+ code: 'HOOK_UPDATED',
259
+ message: 'updated the pre-commit hook',
260
+ };
261
+ case 'skipped':
262
+ return {
263
+ level: 'WARN',
264
+ code: 'HOOK_SKIPPED',
265
+ message: `${result.reason}: ${result.instruction}`,
266
+ };
267
+ case 'failed':
268
+ return {
269
+ level: 'WARN',
270
+ code: 'HOOK_FAILED',
271
+ message: `the pre-commit hook could not be written: ${result.detail}`,
272
+ };
273
+ case 'unchanged':
274
+ case 'disabled':
275
+ return null;
276
+ }
277
+ }
@@ -0,0 +1,65 @@
1
+ import type { HookCommandDeps } from './cli/commands/hookCommand.js';
2
+ import type { ResumeCommandDeps } from './cli/commands/resumeCommand.js';
3
+ import type { RunCommandDeps } from './cli/commands/runCommand.js';
4
+ import type { StatusCommandDeps } from './cli/commands/statusCommand.js';
5
+ import type { EnmetuConfig } from './contracts/config.js';
6
+ import type { IDaemon } from './contracts/IDaemon.js';
7
+ import type { IHookInstaller } from './contracts/IHookInstaller.js';
8
+ import type { IProcessHost } from './contracts/IProcessHost.js';
9
+ import type { IRepositoryLocator, RepositoryPaths } from './contracts/IRepositoryLocator.js';
10
+ import type { Runtime } from './contracts/runtime.js';
11
+ import type { ReadStateDeps } from './library/readStateController.js';
12
+ import { type RecoveryDeps } from './recovery/recoveryController.js';
13
+ import { type SyncCycleDeps } from './sync/syncCycleController.js';
14
+ export declare function createLocator(runtime: Runtime): IRepositoryLocator;
15
+ /**
16
+ * Real git through a halt-guarded writer, for one repository (EN-06). The
17
+ * quarantine and the integrate closure (over integrateUpstreamController) are
18
+ * built on that same guarded writer, so every write the cycle can reach
19
+ * honours the halt.
20
+ */
21
+ export declare function createSyncCycleDeps(paths: RepositoryPaths, config: EnmetuConfig, runtime: Runtime): SyncCycleDeps;
22
+ export declare function createRecoveryDeps(paths: RepositoryPaths, config: EnmetuConfig, runtime: Runtime): RecoveryDeps;
23
+ /**
24
+ * The daemon with IRecovery, IPreflight and ISyncCycle bound into closures over
25
+ * their controllers. Recovery, preflight, hook installer and cycle share one
26
+ * set of repository parts, so one halt store guards every writer. processHost
27
+ * defaults to the operating system's signals; the library passes
28
+ * RequestedStopProcessHost so a host program's process is left alone.
29
+ */
30
+ export declare function createDaemon(paths: RepositoryPaths, config: EnmetuConfig, runtime: Runtime, processHost?: IProcessHost | undefined): IDaemon;
31
+ /**
32
+ * The daemon's hook installer for one repository (design 13.1): it records
33
+ * Runtime.execPath, resolved through the PATH enmetu was started with when it
34
+ * is a bare name, and Runtime.cliPath.
35
+ */
36
+ export declare function createHookInstaller(paths: RepositoryPaths, config: EnmetuConfig, runtime: Runtime): IHookInstaller;
37
+ export declare function createRunDeps(runtime: Runtime): RunCommandDeps;
38
+ /**
39
+ * Status reads only; its quarantine still sits on the guarded writer, so even
40
+ * a mistaken write from status would honour the halt.
41
+ */
42
+ export declare function createStatusDeps(runtime: Runtime): StatusCommandDeps;
43
+ /** Resume gets the unguarded facade: it fetches and moves the verified ref while the marker still exists. */
44
+ export declare function createResumeDeps(runtime: Runtime): ResumeCommandDeps;
45
+ /**
46
+ * The hook's reader runs git with Runtime.env, which carries the
47
+ * GIT_INDEX_FILE git set for the hook, so `commit -a` and a partial commit
48
+ * are checked against the index being committed.
49
+ */
50
+ export declare function createHookCheckDeps(runtime: Runtime): HookCommandDeps;
51
+ /** What the library entry needs to run enmetu in-process (design 12.5). */
52
+ export interface EmbeddedRun {
53
+ /** runCommand's deps; the daemon gets RequestedStopProcessHost over NodeProcessAdapter. */
54
+ readonly deps: RunCommandDeps;
55
+ /** Stops the daemon after the running cycle, like the first SIGTERM. */
56
+ requestStop(): void;
57
+ }
58
+ /**
59
+ * runCommand's deps with one process host shared by the daemon and its lock:
60
+ * the lock still records this process's pid and hostname, while stops come
61
+ * from requestStop() instead of the host program's signals.
62
+ */
63
+ export declare function createEmbeddedRunDeps(runtime: Runtime): EmbeddedRun;
64
+ /** The library's state read: the state file and the instance lock, nothing else (design 12.5). */
65
+ export declare function createReadStateDeps(runtime: Runtime): ReadStateDeps;
@@ -0,0 +1,332 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { join } from 'node:path';
3
+ import { SystemClockAdapter } from './adapters/clock/systemClockAdapter.js';
4
+ import { NodeFileSystemAdapter } from './adapters/fs/nodeFileSystemAdapter.js';
5
+ import { GitFacade } from './adapters/git/gitFacade.js';
6
+ import { GitLocatorFacade } from './adapters/git/gitLocatorFacade.js';
7
+ import { GitProcessAdapter } from './adapters/git/gitProcessAdapter.js';
8
+ import { NullLogger } from './adapters/log/nullLogger.js';
9
+ import { StderrLoggerAdapter } from './adapters/log/stderrLoggerAdapter.js';
10
+ import { NodeProcessAdapter } from './adapters/process/nodeProcessAdapter.js';
11
+ import { FsWatchAdapter, nodeWatch } from './adapters/watch/fsWatchAdapter.js';
12
+ import { commitLocalChangesController, } from './commit/commitLocalChangesController.js';
13
+ import { GitIndexLock } from './commit/gitIndexLock.js';
14
+ import { DEFAULT_CONFIG } from './core/config/parseConfig.js';
15
+ import { Daemon } from './daemon/daemon.js';
16
+ import { HaltGuardGitWriter } from './halt/haltGuardGitWriter.js';
17
+ import { HaltStore } from './halt/haltStore.js';
18
+ import { NullHookInstaller } from './hooks/nullHookInstaller.js';
19
+ import { PreCommitHookInstaller } from './hooks/preCommitHookInstaller.js';
20
+ import { RequestedStopProcessHost } from './library/requestedStopProcessHost.js';
21
+ import { InstanceLock } from './lock/instanceLock.js';
22
+ import { repositoryPreflight } from './preflight/repositoryPreflight.js';
23
+ import { PathQuarantine } from './quarantine/pathQuarantine.js';
24
+ import { OperationJournal } from './recovery/operationJournal.js';
25
+ import { recoveryController, } from './recovery/recoveryController.js';
26
+ import { StateFileStore } from './state/stateFileStore.js';
27
+ import { StateRecorder } from './state/stateRecorder.js';
28
+ import { StateRecordingSyncCycle } from './state/stateRecordingSyncCycle.js';
29
+ import { HookAbortingSyncScheduler } from './sync/hookAbortingSyncScheduler.js';
30
+ import { integrateUpstreamController, } from './sync/integrateUpstreamController.js';
31
+ import { syncCycleController, } from './sync/syncCycleController.js';
32
+ import { SyncCycleRunner } from './sync/syncCycleRunner.js';
33
+ import { SyncScheduler } from './sync/syncScheduler.js';
34
+ /**
35
+ * Factory (composition root): the only production file that calls new on a
36
+ * concrete class or imports an adapter. Every writer the daemon uses is
37
+ * wrapped in HaltGuardGitWriter; resume's writer is not, because resume owns
38
+ * the halt and moves the verified ref before it clears the marker.
39
+ */
40
+ /** The git binary on PATH; the base environment comes from Runtime.env. */
41
+ const GIT_BINARY = 'git';
42
+ /** Decision 23: recovery and the preflight have fixed cycle ids. */
43
+ const RECOVERY_CYCLE_ID = 'recovery';
44
+ const PREFLIGHT_CYCLE_ID = 'startup';
45
+ /**
46
+ * Commands that run before or without the config (locate, status, resume,
47
+ * hook) use the default git timeout; only run reads enmetu.config.json.
48
+ */
49
+ const UNCONFIGURED_GIT_TIMEOUT_MS = DEFAULT_CONFIG.gitTimeoutMs;
50
+ export function createLocator(runtime) {
51
+ return new GitLocatorFacade((cwd) => new GitProcessAdapter({
52
+ cwd,
53
+ baseEnv: runtime.env,
54
+ timeoutMs: UNCONFIGURED_GIT_TIMEOUT_MS,
55
+ gitBinary: GIT_BINARY,
56
+ }));
57
+ }
58
+ /**
59
+ * Real git through a halt-guarded writer, for one repository (EN-06). The
60
+ * quarantine and the integrate closure (over integrateUpstreamController) are
61
+ * built on that same guarded writer, so every write the cycle can reach
62
+ * honours the halt.
63
+ */
64
+ export function createSyncCycleDeps(paths, config, runtime) {
65
+ const parts = guardedRepositoryParts(paths, config.gitTimeoutMs, runtime);
66
+ return syncCycleDepsOf(parts, commitOf(parts, paths, config, runtime, undefined));
67
+ }
68
+ export function createRecoveryDeps(paths, config, runtime) {
69
+ return recoveryDepsOf(guardedRepositoryParts(paths, config.gitTimeoutMs, runtime));
70
+ }
71
+ /**
72
+ * The daemon with IRecovery, IPreflight and ISyncCycle bound into closures over
73
+ * their controllers. Recovery, preflight, hook installer and cycle share one
74
+ * set of repository parts, so one halt store guards every writer. processHost
75
+ * defaults to the operating system's signals; the library passes
76
+ * RequestedStopProcessHost so a host program's process is left alone.
77
+ */
78
+ export function createDaemon(paths, config, runtime, processHost) {
79
+ const parts = guardedRepositoryParts(paths, config.gitTimeoutMs, runtime);
80
+ const host = processHost ?? new NodeProcessAdapter();
81
+ // One recorder for the daemon's lifecycle events, the Decorator's cycle
82
+ // ends and the rebase abort's orphaned commits, so all build on the same
83
+ // in-memory record (design 12.1, 14.15).
84
+ const recorder = new StateRecorder(new StateFileStore(parts.fs, paths.stateDir), parts.clock, parts.logger, config.stateHistory);
85
+ const state = recorder;
86
+ const recoveryDeps = recoveryDepsOf(parts, recorder);
87
+ const recovery = {
88
+ recover: () => recoveryController({ cycleId: RECOVERY_CYCLE_ID }, recoveryDeps),
89
+ };
90
+ const preflight = {
91
+ check: () => repositoryPreflight({ cycleId: PREFLIGHT_CYCLE_ID }, { git: parts.git }),
92
+ };
93
+ // Aborted by the first stop request, so a running repository hook ends at
94
+ // once instead of holding the stop for up to hookTimeoutMs (Decision 92).
95
+ const hookStop = new AbortController();
96
+ const cycleDeps = syncCycleDepsOf(parts, commitOf(parts, paths, config, runtime, hookStop.signal), recorder);
97
+ const cycle = new StateRecordingSyncCycle({ run: (input) => syncCycleController(input, cycleDeps) }, state, parts.git);
98
+ const hookInstaller = hookInstallerOf(parts, paths, config, runtime);
99
+ const scheduler = new HookAbortingSyncScheduler(new SyncScheduler(new SyncCycleRunner(cycle, parts.logger), parts.clock, {
100
+ quietMs: config.quietMs,
101
+ maxDelayMs: config.maxDelayMs,
102
+ pollMs: config.pollMs,
103
+ }), hookStop);
104
+ return new Daemon({ root: paths.root, quietMs: config.quietMs, pollMs: config.pollMs }, lockFor(paths, parts.fs, host, parts.clock), parts.halt, recovery, preflight, hookInstaller, new FsWatchAdapter(paths.root, nodeWatch), scheduler, host, parts.logger, state, parts.clock);
105
+ }
106
+ /**
107
+ * The daemon's hook installer for one repository (design 13.1): it records
108
+ * Runtime.execPath, resolved through the PATH enmetu was started with when it
109
+ * is a bare name, and Runtime.cliPath.
110
+ */
111
+ export function createHookInstaller(paths, config, runtime) {
112
+ return hookInstallerOf(guardedRepositoryParts(paths, config.gitTimeoutMs, runtime), paths, config, runtime);
113
+ }
114
+ // Null Object: the daemon has no branch for installHook (section 2).
115
+ function hookInstallerOf(parts, paths, config, runtime) {
116
+ return config.installHook
117
+ ? new PreCommitHookInstaller(parts.git, parts.fs, paths.hooksDir, { nodePath: runtime.execPath, cliPath: runtime.cliPath }, runtime.env.PATH)
118
+ : new NullHookInstaller();
119
+ }
120
+ export function createRunDeps(runtime) {
121
+ return {
122
+ locator: createLocator(runtime),
123
+ fs: new NodeFileSystemAdapter(),
124
+ logger: new StderrLoggerAdapter(runtime.stderr, new SystemClockAdapter()),
125
+ createDaemon: (paths, config) => createDaemon(paths, config, runtime),
126
+ };
127
+ }
128
+ /**
129
+ * Status reads only; its quarantine still sits on the guarded writer, so even
130
+ * a mistaken write from status would honour the halt.
131
+ */
132
+ export function createStatusDeps(runtime) {
133
+ return {
134
+ locator: createLocator(runtime),
135
+ openSources: (paths) => {
136
+ const parts = guardedRepositoryParts(paths, UNCONFIGURED_GIT_TIMEOUT_MS, runtime);
137
+ return {
138
+ lock: lockFor(paths, parts.fs, new NodeProcessAdapter(), parts.clock),
139
+ halt: parts.halt,
140
+ journal: parts.journal,
141
+ quarantine: parts.quarantine,
142
+ git: parts.git,
143
+ state: new StateFileStore(parts.fs, paths.stateDir),
144
+ };
145
+ },
146
+ stdout: runtime.stdout,
147
+ };
148
+ }
149
+ /** Resume gets the unguarded facade: it fetches and moves the verified ref while the marker still exists. */
150
+ export function createResumeDeps(runtime) {
151
+ return {
152
+ locator: createLocator(runtime),
153
+ openSources: (paths) => {
154
+ const fs = new NodeFileSystemAdapter();
155
+ const facade = facadeFor(paths, UNCONFIGURED_GIT_TIMEOUT_MS, runtime, fs);
156
+ return {
157
+ lock: lockFor(paths, fs, new NodeProcessAdapter(), new SystemClockAdapter()),
158
+ halt: new HaltStore(fs, paths.stateDir),
159
+ git: facade,
160
+ writer: facade,
161
+ };
162
+ },
163
+ stdout: runtime.stdout,
164
+ };
165
+ }
166
+ /**
167
+ * The hook's reader runs git with Runtime.env, which carries the
168
+ * GIT_INDEX_FILE git set for the hook, so `commit -a` and a partial commit
169
+ * are checked against the index being committed.
170
+ */
171
+ export function createHookCheckDeps(runtime) {
172
+ const fs = new NodeFileSystemAdapter();
173
+ return {
174
+ locator: createLocator(runtime),
175
+ fs,
176
+ openGit: (paths) => facadeFor(paths, UNCONFIGURED_GIT_TIMEOUT_MS, runtime, fs),
177
+ stderr: runtime.stderr,
178
+ };
179
+ }
180
+ /**
181
+ * Real git for one repository with every write behind the halt Decorator
182
+ * (D3). A store or facade is never shared across repositories.
183
+ */
184
+ function guardedRepositoryParts(paths, gitTimeoutMs, runtime) {
185
+ const fs = new NodeFileSystemAdapter();
186
+ const clock = new SystemClockAdapter();
187
+ const facade = facadeFor(paths, gitTimeoutMs, runtime, fs);
188
+ const halt = new HaltStore(fs, paths.stateDir);
189
+ const writer = new HaltGuardGitWriter(facade, halt);
190
+ return {
191
+ fs,
192
+ git: facade,
193
+ writer,
194
+ halt,
195
+ journal: new OperationJournal(fs, paths.stateDir),
196
+ quarantine: new PathQuarantine(facade, writer, fs, {
197
+ root: paths.root,
198
+ stateDir: paths.stateDir,
199
+ }),
200
+ logger: new StderrLoggerAdapter(runtime.stderr, clock),
201
+ clock,
202
+ indexLock: new GitIndexLock(fs, new NodeProcessAdapter(), paths.gitDir, paths.stateDir),
203
+ };
204
+ }
205
+ function syncCycleDepsOf(parts, commit, orphans) {
206
+ const integrateDeps = {
207
+ git: parts.git,
208
+ writer: parts.writer,
209
+ halt: parts.halt,
210
+ journal: parts.journal,
211
+ quarantine: parts.quarantine,
212
+ logger: parts.logger,
213
+ clock: parts.clock,
214
+ orphans,
215
+ };
216
+ return {
217
+ git: parts.git,
218
+ writer: parts.writer,
219
+ halt: parts.halt,
220
+ quarantine: parts.quarantine,
221
+ integrate: (input) => integrateUpstreamController(input, integrateDeps),
222
+ commit,
223
+ logger: parts.logger,
224
+ clock: parts.clock,
225
+ };
226
+ }
227
+ function recoveryDepsOf(parts, orphans) {
228
+ return {
229
+ git: parts.git,
230
+ writer: parts.writer,
231
+ journal: parts.journal,
232
+ quarantine: parts.quarantine,
233
+ halt: parts.halt,
234
+ logger: parts.logger,
235
+ clock: parts.clock,
236
+ indexLock: parts.indexLock,
237
+ orphans,
238
+ };
239
+ }
240
+ function facadeFor(paths, gitTimeoutMs, runtime, fs) {
241
+ return new GitFacade(new GitProcessAdapter({
242
+ cwd: paths.root,
243
+ baseEnv: runtime.env,
244
+ timeoutMs: gitTimeoutMs,
245
+ gitBinary: GIT_BINARY,
246
+ }), fs, paths.gitDir);
247
+ }
248
+ /** A fresh random token per process: release() removes only the lock this process wrote. */
249
+ function lockFor(paths, fs, processHost, clock) {
250
+ return new InstanceLock(fs, processHost, clock, paths.stateDir, randomUUID());
251
+ }
252
+ /** The commit index of design 12.7, under the state directory, never committed. */
253
+ const COMMIT_INDEX_FILE = 'commit-index';
254
+ /**
255
+ * The commit step bound into a closure (design 12.7): a second facade whose
256
+ * runner sets GIT_INDEX_FILE to the commit index, its writer behind the same
257
+ * halt Decorator, a real-index facade for post-commit and post-index-change,
258
+ * both facades with the hook timeout and the daemon's hook stop signal
259
+ * (undefined outside the daemon), the index lock and the NullLogger for the
260
+ * residue build.
261
+ */
262
+ function commitOf(parts, paths, config, runtime, hookStop) {
263
+ const commitIndexPath = join(paths.stateDir, COMMIT_INDEX_FILE);
264
+ const commitFacade = new GitFacade(new GitProcessAdapter({
265
+ cwd: paths.root,
266
+ baseEnv: runtime.env,
267
+ timeoutMs: config.gitTimeoutMs,
268
+ gitBinary: GIT_BINARY,
269
+ indexFile: commitIndexPath,
270
+ }), parts.fs, paths.gitDir, config.hookTimeoutMs, hookStop);
271
+ const realHooks = new GitFacade(new GitProcessAdapter({
272
+ cwd: paths.root,
273
+ baseEnv: runtime.env,
274
+ timeoutMs: config.gitTimeoutMs,
275
+ gitBinary: GIT_BINARY,
276
+ }), parts.fs, paths.gitDir, config.hookTimeoutMs, hookStop);
277
+ const deps = {
278
+ git: parts.git,
279
+ writer: parts.writer,
280
+ commitIndex: {
281
+ reader: commitFacade,
282
+ writer: new HaltGuardGitWriter(commitFacade, parts.halt),
283
+ committer: commitFacade,
284
+ path: commitIndexPath,
285
+ },
286
+ realHooks,
287
+ indexLock: parts.indexLock,
288
+ fs: parts.fs,
289
+ logger: parts.logger,
290
+ residueLogger: new NullLogger(),
291
+ clock: parts.clock,
292
+ settings: {
293
+ commitSubject: config.commitSubject,
294
+ skipJsonCheck: config.skipJsonCheck,
295
+ partialStagingWaitMs: config.partialStagingWaitMs,
296
+ commitHooks: config.commitHooks,
297
+ realIndex: join(paths.gitDir, 'index'),
298
+ root: paths.root,
299
+ },
300
+ };
301
+ return (input) => commitLocalChangesController(input, deps);
302
+ }
303
+ /**
304
+ * runCommand's deps with one process host shared by the daemon and its lock:
305
+ * the lock still records this process's pid and hostname, while stops come
306
+ * from requestStop() instead of the host program's signals.
307
+ */
308
+ export function createEmbeddedRunDeps(runtime) {
309
+ const processHost = new RequestedStopProcessHost(new NodeProcessAdapter());
310
+ return {
311
+ deps: {
312
+ locator: createLocator(runtime),
313
+ fs: new NodeFileSystemAdapter(),
314
+ logger: new StderrLoggerAdapter(runtime.stderr, new SystemClockAdapter()),
315
+ createDaemon: (paths, config) => createDaemon(paths, config, runtime, processHost),
316
+ },
317
+ requestStop: () => processHost.requestStop(),
318
+ };
319
+ }
320
+ /** The library's state read: the state file and the instance lock, nothing else (design 12.5). */
321
+ export function createReadStateDeps(runtime) {
322
+ return {
323
+ locator: createLocator(runtime),
324
+ openSources: (paths) => {
325
+ const fs = new NodeFileSystemAdapter();
326
+ return {
327
+ state: new StateFileStore(fs, paths.stateDir),
328
+ lock: lockFor(paths, fs, new NodeProcessAdapter(), new SystemClockAdapter()),
329
+ };
330
+ },
331
+ };
332
+ }