@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,357 @@
1
+ import { EnmetuError, GitCommandError } from '../contracts/errors.js';
2
+ import { withLastMove } from '../core/commit/commitMemory.js';
3
+ import { gitBoolean } from '../core/commit/commitMessage.js';
4
+ import { repairStaleIndexController } from './repairStaleIndexController.js';
5
+ import { stageGuardedChanges } from './stageGuardedChanges.js';
6
+ /** An unchanged tree a hook rejected is offered to the hooks again after this long (design 12.7). */
7
+ export const HOOK_RETRY_MS = 600000;
8
+ /**
9
+ * Controller: enmetu's commit without touching the person's index (design
10
+ * 12.7, Decisions 72 to 74, 80, 81).
11
+ *
12
+ * 1. before = fs.identity(realIndex), read first, so the staged-selection
13
+ * verdict below still holds when the identity is unchanged under the lock.
14
+ * Staged selection: git.hasStagedChanges() on the real index. When it
15
+ * differs and memory.lastMove is set, repairStaleIndexController first
16
+ * (Decision 98): moved, repaired or reverted (Decision 108) forgets lastMove; repaired reads
17
+ * before and the verdict again, so a stale index is never taken for a
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).
22
+ * 2. Build: parent = git.headCommit(); fs.copy(realIndex, commit index);
23
+ * committer.seedFrom(parent); stageGuardedChanges on the commit index.
24
+ * Nothing staged returns nothing.
25
+ * 3. tree = committer.writeTree(); equal to memory.residueTree returns nothing;
26
+ * equal to memory.rejection.tree within HOOK_RETRY_MS returns waiting
27
+ * COMMIT_HOOK_REJECTED without running a hook.
28
+ * 4. commitHooks: pre-commit on the commit index (rejected, timed out or
29
+ * killed: HOOK_REJECTED, waiting, rejection remembered); then re-stage and
30
+ * write the tree again, so a formatter's rewrite is in this commit. The
31
+ * message is commitSubject plus a newline, with or without hooks; the
32
+ * message hooks do not run (Decision 80). The re-stage logs through
33
+ * residueLogger: its EXCLUDED lines repeat the first build's.
34
+ * 5. commit = committer.commitTree({ tree, parent, message, sign: gitBoolean(commit.gpgSign) }).
35
+ * 6. Critical section: indexLock.acquire(commit) (false: busy);
36
+ * 6.1 repositoryState() with an operation: release, paused; HEAD naming
37
+ * anything but input.branchRef: release, busy (Decision 121);
38
+ * 6.2 fs.identity(realIndex) differing from before: release, busy;
39
+ * 6.3 indexLock.install(commit index);
40
+ * 6.4 writer.updateRef(input.branchRef, commit, parent) (a mismatch:
41
+ * restore, release, COMMIT_DISCARDED, busy; any other throw: restore,
42
+ * release, rethrow);
43
+ * 6.4b HEAD naming input.branchRef, or else resolving to the commit, keeps
44
+ * it; otherwise writer.updateRef(input.branchRef, parent, commit),
45
+ * restore, COMMIT_DISCARDED, busy (a roll-back failing with GIT_FAILED
46
+ * restores and returns busy the same way; any other throw restores and
47
+ * rethrows); release.
48
+ * 7. COMMITTED; with hooks: when realHooks.hookExists says post-index-change
49
+ * or post-commit exists, a build with residueLogger before the hooks;
50
+ * post-index-change "0 0" and post-commit on the real index (git ignores
51
+ * both exit codes, so does enmetu); then, only when such a hook exists and
52
+ * the build before found nothing uncommitted, a fresh build whose tree,
53
+ * when it differs from the commit's, becomes residueTree (Decision 90).
54
+ */
55
+ export async function commitLocalChangesController(input, deps) {
56
+ const { cycleId } = input;
57
+ const { settings } = deps;
58
+ const now = deps.clock.now();
59
+ // --- Step 1: identity first, then the person's staged selection ---
60
+ let memory = input.memory;
61
+ let before = await deps.fs.identity(settings.realIndex);
62
+ let staged = await deps.git.hasStagedChanges();
63
+ if (staged && memory.lastMove !== null) {
64
+ const repair = await repairStaleIndexController({ cycleId, lastMove: memory.lastMove, before }, deps);
65
+ // reverted is forgotten too, so its STAGED_REVERT line comes once (Decision 108).
66
+ if (repair.kind === 'moved' ||
67
+ repair.kind === 'repaired' ||
68
+ repair.kind === 'reverted') {
69
+ memory = withLastMove(memory, null);
70
+ }
71
+ if (repair.kind === 'repaired') {
72
+ before = await deps.fs.identity(settings.realIndex);
73
+ staged = await deps.git.hasStagedChanges();
74
+ }
75
+ }
76
+ let since = null;
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
+ };
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
+ });
93
+ }
94
+ const unchanged = rememberSince(memory, since);
95
+ // --- Step 2: build on the commit index, seeded from HEAD ---
96
+ const parent = await deps.git.headCommit();
97
+ if (parent === null) {
98
+ // The preflight refuses an unborn HEAD; this guards the type.
99
+ throw new EnmetuError('HEAD is unborn; there is nothing to commit onto', 'HEAD_UNBORN', cycleId);
100
+ }
101
+ if (!(await build(parent, cycleId, deps.logger, deps))) {
102
+ return { kind: 'nothing', memory: unchanged };
103
+ }
104
+ // --- Step 3: trees enmetu already knows ---
105
+ let tree = await deps.commitIndex.committer.writeTree();
106
+ if (tree === memory.residueTree) {
107
+ return { kind: 'nothing', memory: unchanged };
108
+ }
109
+ const rejection = memory.rejection;
110
+ if (rejection !== null &&
111
+ tree === rejection.tree &&
112
+ now - rejection.at < HOOK_RETRY_MS) {
113
+ return {
114
+ kind: 'waiting',
115
+ reason: 'COMMIT_HOOK_REJECTED',
116
+ memory: unchanged,
117
+ };
118
+ }
119
+ // --- Step 4: pre-commit on the commit index, then re-stage its rewrites ---
120
+ if (settings.commitHooks) {
121
+ const preCommit = await deps.commitIndex.committer.runHook('pre-commit', []);
122
+ if (preCommit.kind === 'rejected') {
123
+ deps.logger.log({
124
+ level: 'WARN',
125
+ code: 'HOOK_REJECTED',
126
+ message: `pre-commit rejected the commit (${firstLine(preCommit.detail)}); enmetu offers it again when the files change, or after ${HOOK_RETRY_MS} ms`,
127
+ cycleId,
128
+ });
129
+ return {
130
+ kind: 'waiting',
131
+ reason: 'COMMIT_HOOK_REJECTED',
132
+ memory: {
133
+ partialStagingSince: since,
134
+ rejection: { tree, at: now },
135
+ residueTree: memory.residueTree,
136
+ lastMove: memory.lastMove,
137
+ },
138
+ };
139
+ }
140
+ const restaged = await stageGuardedChanges({ cycleId }, {
141
+ git: deps.commitIndex.reader,
142
+ writer: deps.commitIndex.writer,
143
+ logger: deps.residueLogger,
144
+ skipJsonCheck: settings.skipJsonCheck,
145
+ });
146
+ if (!restaged.staged) {
147
+ return { kind: 'nothing', memory: unchanged };
148
+ }
149
+ tree = await deps.commitIndex.committer.writeTree();
150
+ }
151
+ // --- Step 5: the commit object; HEAD is its only parent, never MERGE_HEAD ---
152
+ // The subject is the whole message: no path ever reaches it (D1).
153
+ const commit = await deps.commitIndex.committer.commitTree({
154
+ tree,
155
+ parent,
156
+ message: `${settings.commitSubject}\n`,
157
+ sign: gitBoolean(await deps.git.configValue('commit.gpgSign')),
158
+ });
159
+ // --- Step 6: critical section under git's own index.lock ---
160
+ const swap = await installAndSwap(commit, parent, before, input, deps);
161
+ if (swap !== 'committed') {
162
+ return swap === 'busy'
163
+ ? { kind: 'busy', memory: unchanged }
164
+ : { kind: 'paused', reason: 'FOREIGN_OPERATION', memory: unchanged };
165
+ }
166
+ // --- Step 7: after the lock ---
167
+ deps.logger.log({
168
+ level: 'INFO',
169
+ code: 'COMMITTED',
170
+ message: 'local changes',
171
+ cycleId,
172
+ });
173
+ let residueTree = null;
174
+ if (settings.commitHooks) {
175
+ residueTree = await runPostCommitHooks(commit, tree, cycleId, deps);
176
+ }
177
+ return {
178
+ kind: 'committed',
179
+ memory: {
180
+ partialStagingSince: null,
181
+ rejection: null,
182
+ residueTree,
183
+ lastMove: { from: parent, to: commit },
184
+ },
185
+ };
186
+ }
187
+ /**
188
+ * Step 7 with hooks: post-index-change and post-commit on the real index, and
189
+ * the residue they leave (Decision 90). A residue is built only when one of
190
+ * them exists, so a repository without such hooks never mistakes a person's
191
+ * write for hook output. A build before the
192
+ * hooks tells what was already uncommitted: anything there (a person's write
193
+ * since the commit's build) means no residue at all, so the next cycle
194
+ * commits that write together with whatever the hooks wrote, and the cycle
195
+ * after it finds the residue alone.
196
+ */
197
+ async function runPostCommitHooks(commit, tree, cycleId, deps) {
198
+ const mayWrite = (await deps.realHooks.hookExists('post-index-change')) ||
199
+ (await deps.realHooks.hookExists('post-commit'));
200
+ const pendingBefore = mayWrite && (await build(commit, cycleId, deps.residueLogger, deps));
201
+ // Both run through `git hook run --ignore-missing` whatever hookExists
202
+ // said, so what runs is git's decision alone; git ignores both exit codes,
203
+ // and so does enmetu.
204
+ await deps.realHooks.runHook('post-index-change', ['0', '0']);
205
+ await deps.realHooks.runHook('post-commit', []);
206
+ if (!mayWrite || pendingBefore) {
207
+ return null;
208
+ }
209
+ if (!(await build(commit, cycleId, deps.residueLogger, deps))) {
210
+ return null;
211
+ }
212
+ const residue = await deps.commitIndex.committer.writeTree();
213
+ return residue === tree ? null : residue;
214
+ }
215
+ /**
216
+ * Step 2 of the chain: copy the real index (git's stat data and skip-worktree
217
+ * bits, so add -A does not re-hash every file), seed it with the commit's
218
+ * tree, stage the guarded working tree. true when anything is staged.
219
+ */
220
+ async function build(seed, cycleId, logger, deps) {
221
+ const { commitIndex } = deps;
222
+ if (!(await deps.fs.copy(deps.settings.realIndex, commitIndex.path))) {
223
+ // No real index (git reads a missing index like an empty one): a leftover commit index must
224
+ // not lend its stat data.
225
+ await deps.fs.remove(commitIndex.path);
226
+ }
227
+ await commitIndex.committer.seedFrom(seed);
228
+ const { staged } = await stageGuardedChanges({ cycleId }, {
229
+ git: commitIndex.reader,
230
+ writer: commitIndex.writer,
231
+ logger,
232
+ skipJsonCheck: deps.settings.skipJsonCheck,
233
+ });
234
+ return staged;
235
+ }
236
+ /**
237
+ * Step 6: under index.lock, in this order (Decisions 72, 81, 121):
238
+ *
239
+ * 6.1 repositoryState(): an operation pauses; HEAD naming anything but
240
+ * branchRef (a person's git switch -c or --detach, which git lets through
241
+ * a held index.lock) is busy, nothing installed.
242
+ * 6.2 The real index's identity still equals before, else busy.
243
+ * 6.3 Install the commit index, so a git commit that reads enmetu's branch
244
+ * also reads enmetu's index.
245
+ * 6.4 Compare-and-swap of branchRef, not HEAD: a swap through HEAD would
246
+ * follow a switch made after 6.1 onto the person's branch.
247
+ * 6.4b HEAD still names branchRef, or resolves to the commit (a branch
248
+ * created or a detach made after the swap carries the commit along);
249
+ * otherwise the swap is undone by compare-and-swap and the index restored.
250
+ *
251
+ * The lock is released on every path.
252
+ */
253
+ async function installAndSwap(commit, parent, before, input, deps) {
254
+ const { cycleId, branchRef } = input;
255
+ if (!(await deps.indexLock.acquire(commit))) {
256
+ return 'busy';
257
+ }
258
+ let installing = false;
259
+ let settled = false;
260
+ try {
261
+ // --- 6.1 ---
262
+ const state = await deps.git.repositoryState();
263
+ if (state.operation !== null) {
264
+ return 'paused';
265
+ }
266
+ if (state.branchRef !== branchRef) {
267
+ return 'busy';
268
+ }
269
+ // --- 6.2: a write of a person's git to the real index since step 1
270
+ // replaced the file; installing over it would discard what they staged ---
271
+ if ((await deps.fs.identity(deps.settings.realIndex)) !== before) {
272
+ return 'busy';
273
+ }
274
+ // --- 6.3 ---
275
+ installing = true;
276
+ await deps.indexLock.install(deps.commitIndex.path);
277
+ // --- 6.4 ---
278
+ try {
279
+ await deps.writer.updateRef(branchRef, commit, parent);
280
+ }
281
+ catch (error) {
282
+ settled = true;
283
+ await deps.indexLock.restore();
284
+ if (error instanceof GitCommandError && error.code === 'GIT_FAILED') {
285
+ deps.logger.log({
286
+ level: 'INFO',
287
+ code: 'COMMIT_DISCARDED',
288
+ message: 'a concurrent commit moved HEAD first; enmetu discards its commit and commits again on a later cycle',
289
+ cycleId,
290
+ });
291
+ return 'busy';
292
+ }
293
+ throw error;
294
+ }
295
+ // From here the branch holds the commit and the installed index matches
296
+ // it; only a roll-back puts the person's index back, so a failed read
297
+ // below never leaves the index behind the branch.
298
+ settled = true;
299
+ // --- 6.4b ---
300
+ if (await headStillOnCommit(branchRef, commit, deps.git)) {
301
+ return 'committed';
302
+ }
303
+ await rollBack(branchRef, commit, parent, cycleId, deps);
304
+ return 'busy';
305
+ }
306
+ catch (error) {
307
+ if (installing && !settled) {
308
+ await deps.indexLock.restore();
309
+ }
310
+ throw error;
311
+ }
312
+ finally {
313
+ await deps.indexLock.release();
314
+ }
315
+ }
316
+ /** 6.4b: HEAD names branchRef, or, asked only when it does not, resolves to the commit. */
317
+ async function headStillOnCommit(branchRef, commit, git) {
318
+ if ((await git.repositoryState()).branchRef === branchRef) {
319
+ return true;
320
+ }
321
+ return (await git.headCommit()) === commit;
322
+ }
323
+ /**
324
+ * 6.4b's compensation: branchRef back from the commit to its parent by
325
+ * compare-and-swap, then the person's index. A roll-back git refuses
326
+ * (GIT_FAILED: another command moved the branch after the swap) leaves the
327
+ * branch where that command put it; any other throw restores and rethrows.
328
+ */
329
+ async function rollBack(branchRef, commit, parent, cycleId, deps) {
330
+ try {
331
+ await deps.writer.updateRef(branchRef, parent, commit);
332
+ }
333
+ catch (error) {
334
+ if (!(error instanceof GitCommandError && error.code === 'GIT_FAILED')) {
335
+ await deps.indexLock.restore();
336
+ throw error;
337
+ }
338
+ }
339
+ await deps.indexLock.restore();
340
+ deps.logger.log({
341
+ level: 'INFO',
342
+ code: 'COMMIT_DISCARDED',
343
+ message: `HEAD left ${branchRef} while enmetu moved it to its commit (a git switch of yours, most likely); enmetu put ${branchRef} and the index back and commits again on a later cycle`,
344
+ cycleId,
345
+ });
346
+ }
347
+ function rememberSince(memory, since) {
348
+ return {
349
+ partialStagingSince: since,
350
+ rejection: memory.rejection,
351
+ residueTree: memory.residueTree,
352
+ lastMove: memory.lastMove,
353
+ };
354
+ }
355
+ function firstLine(text) {
356
+ return (text.trim().split('\n')[0] ?? '').trim();
357
+ }
@@ -0,0 +1,36 @@
1
+ import type { IFileSystem } from '../contracts/IFileSystem.js';
2
+ import type { IGitIndexLock, StaleIndexLock } from '../contracts/IGitIndexLock.js';
3
+ import type { IProcessHost } from '../contracts/IProcessHost.js';
4
+ /** index.lock's content while enmetu holds it: `${INDEX_LOCK_MARKER}<pid>, commit <commit>\n`. */
5
+ export declare const INDEX_LOCK_MARKER = "enmetu index lock, pid ";
6
+ /** The person's index, kept under a second name while enmetu's commit index is installed. */
7
+ export declare const INDEX_BEFORE_FILE = "index.before";
8
+ /**
9
+ * Service: <gitDir>/index.lock (design 12.7, Decision 81).
10
+ * acquire(commit) is createExclusive(index.lock, the marker with pid and
11
+ * commit). install(prepared) removes a leftover <stateDir>/index.before, links
12
+ * <gitDir>/index to it (no link when there is no real index), then renames the
13
+ * prepared index over <gitDir>/index. restore() renames index.before back over
14
+ * <gitDir>/index. release() removes index.before, then the lock, and only when
15
+ * this instance holds it. releaseStale(head) reads the lock: not the marker, or
16
+ * a live pid: none. Otherwise HEAD equal to the marker's commit means the
17
+ * install happened before the crash (it precedes the swap): index.before is
18
+ * removed, kept. Any other HEAD: index.before, when present, is renamed back,
19
+ * restored. Then the lock is removed.
20
+ */
21
+ export declare class GitIndexLock implements IGitIndexLock {
22
+ private readonly fs;
23
+ private readonly processHost;
24
+ private readonly gitDir;
25
+ private readonly stateDir;
26
+ private held;
27
+ constructor(fs: IFileSystem, processHost: IProcessHost, gitDir: string, stateDir: string);
28
+ acquire(commit: string): Promise<boolean>;
29
+ install(preparedIndex: string): Promise<void>;
30
+ restore(): Promise<void>;
31
+ release(): Promise<void>;
32
+ releaseStale(head: string | null): Promise<StaleIndexLock>;
33
+ private lockPath;
34
+ private indexPath;
35
+ private beforePath;
36
+ }
@@ -0,0 +1,90 @@
1
+ import { join } from 'node:path';
2
+ /** index.lock's content while enmetu holds it: `${INDEX_LOCK_MARKER}<pid>, commit <commit>\n`. */
3
+ export const INDEX_LOCK_MARKER = 'enmetu index lock, pid ';
4
+ /** The person's index, kept under a second name while enmetu's commit index is installed. */
5
+ export const INDEX_BEFORE_FILE = 'index.before';
6
+ const MARKER_LINE = /^enmetu index lock, pid ([0-9]+), commit (\S+)\n?$/;
7
+ /**
8
+ * Service: <gitDir>/index.lock (design 12.7, Decision 81).
9
+ * acquire(commit) is createExclusive(index.lock, the marker with pid and
10
+ * commit). install(prepared) removes a leftover <stateDir>/index.before, links
11
+ * <gitDir>/index to it (no link when there is no real index), then renames the
12
+ * prepared index over <gitDir>/index. restore() renames index.before back over
13
+ * <gitDir>/index. release() removes index.before, then the lock, and only when
14
+ * this instance holds it. releaseStale(head) reads the lock: not the marker, or
15
+ * a live pid: none. Otherwise HEAD equal to the marker's commit means the
16
+ * install happened before the crash (it precedes the swap): index.before is
17
+ * removed, kept. Any other HEAD: index.before, when present, is renamed back,
18
+ * restored. Then the lock is removed.
19
+ */
20
+ export class GitIndexLock {
21
+ fs;
22
+ processHost;
23
+ gitDir;
24
+ stateDir;
25
+ held = false;
26
+ constructor(fs, processHost, gitDir, stateDir) {
27
+ this.fs = fs;
28
+ this.processHost = processHost;
29
+ this.gitDir = gitDir;
30
+ this.stateDir = stateDir;
31
+ }
32
+ async acquire(commit) {
33
+ const created = await this.fs.createExclusive(this.lockPath(), `${INDEX_LOCK_MARKER}${this.processHost.pid}, commit ${commit}\n`);
34
+ this.held = created;
35
+ return created;
36
+ }
37
+ async install(preparedIndex) {
38
+ // A leftover from a section that ended without release would make link(2)
39
+ // fail with EEXIST.
40
+ await this.fs.remove(this.beforePath());
41
+ // false: the repository has no index yet (git reads a missing index like an empty one), so
42
+ // there is nothing of the person's to keep.
43
+ await this.fs.link(this.indexPath(), this.beforePath());
44
+ await this.fs.rename(preparedIndex, this.indexPath());
45
+ }
46
+ async restore() {
47
+ await this.fs.rename(this.beforePath(), this.indexPath());
48
+ }
49
+ async release() {
50
+ if (!this.held) {
51
+ return;
52
+ }
53
+ await this.fs.remove(this.beforePath());
54
+ await this.fs.remove(this.lockPath());
55
+ this.held = false;
56
+ }
57
+ async releaseStale(head) {
58
+ const content = await this.fs.readText(this.lockPath());
59
+ if (content === null) {
60
+ return { kind: 'none' };
61
+ }
62
+ const match = MARKER_LINE.exec(content);
63
+ const pid = match?.[1];
64
+ const commit = match?.[2];
65
+ if (pid === undefined || commit === undefined) {
66
+ return { kind: 'none' };
67
+ }
68
+ if (this.processHost.isAlive(Number(pid))) {
69
+ return { kind: 'none' };
70
+ }
71
+ if (head === commit) {
72
+ await this.fs.remove(this.beforePath());
73
+ await this.fs.remove(this.lockPath());
74
+ return { kind: 'released', index: 'kept' };
75
+ }
76
+ // false when the crash came before the link: nothing was installed.
77
+ await this.fs.rename(this.beforePath(), this.indexPath());
78
+ await this.fs.remove(this.lockPath());
79
+ return { kind: 'released', index: 'restored' };
80
+ }
81
+ lockPath() {
82
+ return join(this.gitDir, 'index.lock');
83
+ }
84
+ indexPath() {
85
+ return join(this.gitDir, 'index');
86
+ }
87
+ beforePath() {
88
+ return join(this.stateDir, INDEX_BEFORE_FILE);
89
+ }
90
+ }
@@ -0,0 +1,90 @@
1
+ import type { IFileSystem } from '../contracts/IFileSystem.js';
2
+ import type { IGitIndexLock } from '../contracts/IGitIndexLock.js';
3
+ import type { IGitReader } from '../contracts/IGitReader.js';
4
+ import type { IGitWriter } from '../contracts/IGitWriter.js';
5
+ import type { ILogger } from '../contracts/ILogger.js';
6
+ import type { HeadMove } from '../contracts/syncTypes.js';
7
+ /** At most this many repaired paths are named in the log line; the count is always exact. */
8
+ export declare const MAX_REPAIRED_PATHS_LOGGED = 20;
9
+ export interface RepairStaleIndexInput {
10
+ readonly cycleId: string;
11
+ readonly lastMove: HeadMove;
12
+ /** fs.identity(realIndex), read before the staged paths, so an unchanged identity under the lock means they still hold. */
13
+ readonly before: string | null;
14
+ }
15
+ export interface RepairStaleIndexDeps {
16
+ /** The real index and HEAD. */
17
+ readonly git: IGitReader;
18
+ readonly commitIndex: {
19
+ /** Behind HaltGuardGitWriter; its unstage sets entries back to HEAD's on the commit index. */
20
+ readonly writer: IGitWriter;
21
+ /** <stateDir>/commit-index */
22
+ readonly path: string;
23
+ };
24
+ readonly indexLock: IGitIndexLock;
25
+ readonly fs: IFileSystem;
26
+ readonly logger: ILogger;
27
+ readonly settings: {
28
+ /** The working tree root, to read a path's working-tree side. */
29
+ readonly root: string;
30
+ /** <gitDir>/index */
31
+ readonly realIndex: string;
32
+ };
33
+ }
34
+ export type RepairStaleIndexResult =
35
+ /** HEAD is no longer lastMove.to: the memory has nothing left to repair against. */
36
+ {
37
+ readonly kind: 'moved';
38
+ }
39
+ /** No staged path carries the stale signature: nothing touched, the memory stays. */
40
+ | {
41
+ readonly kind: 'none';
42
+ }
43
+ /** index.lock held, an operation started or the real index changed meanwhile: nothing touched, tried again next cycle. */
44
+ | {
45
+ readonly kind: 'busy';
46
+ } | {
47
+ readonly kind: 'repaired';
48
+ readonly paths: readonly string[];
49
+ }
50
+ /**
51
+ * No stale path, but staged paths whose index entry AND working tree are
52
+ * lastMove.from's: a failed git merge's restore_state left them (design
53
+ * 13.2, Decision 108), or the person reverted them. Named once in
54
+ * STAGED_REVERT, nothing written; the caller forgets lastMove, so the line
55
+ * is not repeated. A cycle that also repairs returns repaired and logs both.
56
+ */
57
+ | {
58
+ readonly kind: 'reverted';
59
+ readonly paths: readonly string[];
60
+ };
61
+ /**
62
+ * Controller: sets back index entries that a person's git command wrote back
63
+ * from before enmetu's last move of HEAD, its commit or its pull (design
64
+ * 12.7, Decision 98). A command that read the real index before the move and
65
+ * writes it after (measured for git merge against enmetu's commit section;
66
+ * seen in the stress scenario against enmetu's rebase) leaves HEAD at
67
+ * lastMove.to and lastMove.from's entries in the index, which would revert
68
+ * the move in the person's next commit.
69
+ *
70
+ * 1. HEAD differs from lastMove.to: moved.
71
+ * 2. Candidates: git.stagedPaths() (index against HEAD), in git's order. A
72
+ * path is stale when its index entry equals lastMove.from's (both absent
73
+ * for a path the move added) while the working tree does not (absent, or
74
+ * a regular file whose filtered blob is not from's; a link or a directory
75
+ * is never stale). A path the move did not change has from's entry in
76
+ * HEAD, so it differs from HEAD only with another entry and never matches.
77
+ * Reverted: index entry and working tree both from's (design 13.2,
78
+ * Decision 108). Neither: none. Only reverted: STAGED_REVERT once, nothing
79
+ * written, reverted.
80
+ * 3. Prepare: fs.copy(realIndex, commit index), so every other entry, the
81
+ * person's staged selection included, stays unchanged; commitIndex.writer
82
+ * .unstage(stale) sets exactly those entries to HEAD's.
83
+ * 4. Critical section, shaped like the commit's (Decision 81): indexLock.acquire
84
+ * (false: busy); an operation in progress or the real index's identity
85
+ * differing from before: release, busy; HEAD moved: release, moved;
86
+ * indexLock.install(commit index); release on every path.
87
+ * 5. INDEX_REPAIRED once, naming the paths, then STAGED_REVERT for reverted
88
+ * paths; repaired. Busy or moved names nothing, so the next cycle does.
89
+ */
90
+ export declare function repairStaleIndexController(input: RepairStaleIndexInput, deps: RepairStaleIndexDeps): Promise<RepairStaleIndexResult>;