@fluado/enmetu 0.0.0-stage → 0.1.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 +168 -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 +72 -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 +28 -0
  22. package/dist/cli/commands/hookCommand.js +90 -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 +33 -0
  26. package/dist/cli/commands/runCommand.js +69 -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 +39 -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 +358 -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 +28 -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 +20 -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 +5 -0
  116. package/dist/core/config/parseConfig.js +196 -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 +51 -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 +333 -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 +113 -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,302 @@
1
+ import { findContentViolation } from '../core/content/contentRules.js';
2
+ import { writesDuringStoppedRebase } from '../core/git/stoppedRebaseWrites.js';
3
+ import { recordHalt } from '../halt/recordHalt.js';
4
+ /**
5
+ * Capture, abort and verify (1.11 abort-and-verify, 1.14 step 2): the one
6
+ * implementation for the live cycle and for startup recovery. The caller has
7
+ * already proven the rebase in progress is enmetu's (HEAD's reflog carries the
8
+ * attempt's token, or, without a reflog, orig-head and onto match; Decision 104).
9
+ *
10
+ * 0a. branchRef = git.rebaseHeadName(): the branch the rebase returns to,
11
+ * null for a rebase started on a detached HEAD (Decision 122).
12
+ * 0. Read the writes that landed during the stop (Decision 45).
13
+ * 0b. Name: every staged path the abort resets that is not written, not
14
+ * changed by the stopped commit and not orig-head's version is logged
15
+ * STAGED_RESET with its stage-0 blob (Decisions 105, 115); nothing is
16
+ * written for it.
17
+ * 0c. Capture, the last step before the abort: the status is read again, the
18
+ * writes go through quarantine.extend, and the status is read again after
19
+ * each capture, up to MAX_CAPTURE_ROUNDS captures, so a write that landed
20
+ * while enmetu was naming or capturing is held too (Decision 113). A
21
+ * written path the stopped commit changes that holds git's conflict
22
+ * markers is not captured but named, and the abort resets it (Decision
23
+ * 118). A refused capture halts REBASE_ABORT_UNVERIFIED with the rebase
24
+ * left in progress.
25
+ * 1a. A commit made on the rebase's detached HEAD (HEAD's newest reflog
26
+ * entry is not the attempt's, and the attempt's last HEAD is an ancestor
27
+ * of HEAD) is left on no branch by step 1 and its changes leave the disk:
28
+ * COMMIT_ORPHANED names it with the remedy (Decision 124), and deps.orphans
29
+ * records it for `enmetu status` (design 14.15).
30
+ * 1. tip = git.resolveCommit(branchRef), the last read before git's write. A
31
+ * tip other than origHead (a commit moved the branch while the rebase
32
+ * ran): writer.endRebaseAtBranch(branchRef) and REBASE_BRANCH_MOVED;
33
+ * otherwise writer.abortRebase() (Decision 122).
34
+ * 2. Verify: no operation, nothing unmerged, HEAD at the moved tip or at
35
+ * origHead and, with a branchRef, HEAD naming it; a failure halts
36
+ * REBASE_ABORT_UNVERIFIED, none clears the journal. branchMovedTo carries
37
+ * the tip step 1 ended at.
38
+ */
39
+ export async function abortEnmetuRebase(input, deps) {
40
+ const { cycleId, origHead, onto } = input;
41
+ // --- Step 0a: the branch the rebase returns to (Decision 122) ---
42
+ const branchRef = await deps.git.rebaseHeadName();
43
+ // --- Step 0: the abort resets every tracked file; these were written ---
44
+ const written = writesDuringStoppedRebase(await deps.git.status());
45
+ const pickPaths = await stoppedCommitPaths(deps);
46
+ // --- Step 0b: a person's staged write is reset by the abort; name its blob ---
47
+ await nameStagedWrites(input, written, pickPaths, deps);
48
+ // --- Step 0c: hold every write aside, reading again until none is new ---
49
+ const capture = await captureWrites(input, pickPaths, deps);
50
+ if (capture.kind === 'refused') {
51
+ // Aborting would discard the write; the rebase stays for a person to end.
52
+ const reason = capture.result.kind === 'unsupported'
53
+ ? `${capture.result.path} was written while the rebase was stopped and cannot be held (a symlink or a directory); end the rebase by hand once that path is safe`
54
+ : 'the quarantine memento cannot be read, so the writes made while the rebase was stopped cannot be held; end the rebase by hand once those paths are safe';
55
+ await recordHalt({
56
+ code: 'REBASE_ABORT_UNVERIFIED',
57
+ message: `enmetu's rebase of ${origHead} onto ${onto} was left in progress: ${reason}; the rebase journal and any held paths are kept`,
58
+ cycleId,
59
+ }, deps);
60
+ return {
61
+ verdict: 'unverified',
62
+ captured: capture.captured,
63
+ branchMovedTo: null,
64
+ };
65
+ }
66
+ const { captured } = capture;
67
+ // --- Step 1a: a commit on the rebase's detached HEAD is named, not kept ---
68
+ await nameOrphanedCommits(input, deps);
69
+ // --- Step 1: the branch's tip, the last read before git's write; a moved
70
+ // branch ends the rebase there, since git rebase --abort would reset the
71
+ // branch to orig-head with no old-value check and drop the commit that
72
+ // moved it. Neither exit code says anything the verification does not ---
73
+ const tip = branchRef === null ? null : await deps.git.resolveCommit(branchRef);
74
+ const branchMovedTo = branchRef !== null && tip !== null && tip !== origHead ? tip : null;
75
+ if (branchRef !== null && branchMovedTo !== null) {
76
+ await deps.writer.endRebaseAtBranch(branchRef);
77
+ deps.logger.log({
78
+ level: 'WARN',
79
+ code: 'REBASE_BRANCH_MOVED',
80
+ message: `${branchRef} moved from ${shortSha(origHead)} to ${shortSha(branchMovedTo)} while enmetu's rebase onto ${shortSha(onto)} ran (a commit made meanwhile); git rebase --abort would have reset it to ${shortSha(origHead)} and dropped that commit, so enmetu ended its rebase at ${shortSha(branchMovedTo)} and rebases again on the next cycle`,
81
+ cycleId,
82
+ });
83
+ }
84
+ else {
85
+ await deps.writer.abortRebase();
86
+ }
87
+ // --- Step 2: verify HEAD and, with a head-name, the branch HEAD names ---
88
+ const failure = await rebaseEndFailure({ head: branchMovedTo ?? origHead, branchRef }, deps);
89
+ if (failure !== null) {
90
+ await recordAbortUnverified(input, failure, deps);
91
+ return { verdict: 'unverified', captured, branchMovedTo };
92
+ }
93
+ await deps.journal.clear();
94
+ return { verdict: 'verified', captured, branchMovedTo };
95
+ }
96
+ /**
97
+ * Step 1a (design 14.14, Decision 124). A person's `git commit` (or merge)
98
+ * while enmetu's rebase is in progress lands on the rebase's detached HEAD:
99
+ * status is clean afterwards, so step 0 captures nothing and step 0b names
100
+ * nothing, and both `git rebase --abort` and endRebaseAtBranch leave that
101
+ * commit on no branch and set the tree to the branch's version. Keeping it
102
+ * would mean replaying it onto the branch, a conflict wherever it holds git's
103
+ * markers from the stopped pick (a `git add -A`), so it is named instead, as
104
+ * step 0b names a staged write: the commit stays in HEAD's reflog until git
105
+ * gc expires it. Only commits built on the rebase's own last HEAD are named;
106
+ * HEAD moved anywhere else (a checkout, a reset) made no commit to lose.
107
+ */
108
+ async function nameOrphanedCommits(input, deps) {
109
+ if (input.reflogAction === null) {
110
+ return;
111
+ }
112
+ const move = await deps.git.headMovedSince(input.reflogAction);
113
+ if (move === null ||
114
+ move.from === move.to ||
115
+ !(await deps.git.isAncestor(move.from, move.to))) {
116
+ return;
117
+ }
118
+ deps.logger.log({
119
+ level: 'WARN',
120
+ code: 'COMMIT_ORPHANED',
121
+ message: `HEAD moved from ${shortSha(move.from)} to ${move.to} while enmetu's rebase onto ${shortSha(input.onto)} was in progress (a commit made on the rebase's detached HEAD, a git commit of yours most likely); ending the rebase leaves that commit on no branch and takes its changes off disk; git cherry-pick -x ${move.from}..${move.to} puts it on your branch (a file the rebase stopped on may hold git's conflict markers in that commit: keep your branch's version with git checkout --ours -- <path>), and git reflog keeps it until git gc expires it; enmetu status lists it until a local branch has it`,
122
+ cycleId: input.cycleId,
123
+ });
124
+ if (deps.orphans !== undefined) {
125
+ await deps.orphans.orphaned({
126
+ commit: move.to,
127
+ base: move.from,
128
+ subject: await subjectOrNothing(move.to, deps),
129
+ });
130
+ }
131
+ }
132
+ /**
133
+ * The subject only labels the status line; a failed read (a git timeout) must
134
+ * not keep step 1 from ending the rebase, so it records no subject instead.
135
+ */
136
+ async function subjectOrNothing(commit, deps) {
137
+ try {
138
+ return await deps.git.commitSubject(commit);
139
+ }
140
+ catch {
141
+ return '';
142
+ }
143
+ }
144
+ const SHORT_SHA_LENGTH = 7;
145
+ function shortSha(sha) {
146
+ return sha.slice(0, SHORT_SHA_LENGTH);
147
+ }
148
+ /**
149
+ * How many captures step 0c runs at most before the abort. Each capture takes
150
+ * a few git spawns; a write that lands during one is found by the next status
151
+ * read. The bound keeps a file saved over and over from holding the abort
152
+ * back for ever; what stays open is the time between the last status read and
153
+ * the reset inside `git rebase --abort` (design 13.11).
154
+ */
155
+ export const MAX_CAPTURE_ROUNDS = 3;
156
+ /**
157
+ * Step 0c (Decision 113). `git rebase --abort` resets every tracked file to
158
+ * orig-head whatever it holds, so a write that lands after the capture read
159
+ * the status, and before the abort, is overwritten without a record; the
160
+ * status is read again after every capture, and new writes are captured too.
161
+ * Git's own conflict markers in a path the stopped commit changes are not
162
+ * captured (Decision 118, splitMarkerLeftovers).
163
+ */
164
+ async function captureWrites(input, pickPaths, deps) {
165
+ const leftovers = new Set();
166
+ // Read afresh, not reused from step 0: step 0b's spawns ran since.
167
+ let pending = await splitMarkerLeftovers(input, pickPaths, leftovers, deps);
168
+ let captured = false;
169
+ for (let round = 0; round < MAX_CAPTURE_ROUNDS && pending.length > 0; round += 1) {
170
+ const result = await deps.quarantine.extend(pending);
171
+ if (result.kind !== 'held') {
172
+ return { kind: 'refused', result, captured };
173
+ }
174
+ captured = true;
175
+ pending = await splitMarkerLeftovers(input, pickPaths, leftovers, deps);
176
+ }
177
+ return { kind: 'captured', captured };
178
+ }
179
+ /**
180
+ * The writes to capture, read from a fresh status, without git's leftovers
181
+ * (design 13.11, Decision 118). A person's git command that writes the index
182
+ * while enmetu's rebase is stopped (a `git merge` that read HEAD before
183
+ * enmetu's checkout and writes its index back) turns the conflicted path into
184
+ * a stage-0 entry and leaves git's conflict markers on disk, so the path looks
185
+ * like a person's write. Captured and restored after the abort, the markers would make
186
+ * enmetu exclude the path (CONFLICT_MARKERS) and the pre-commit hook refuse
187
+ * every commit. A written path the stopped commit changes whose bytes carry
188
+ * conflict markers (D11's rule) is therefore left to the abort, which resets
189
+ * it to orig-head, and its bytes are stored and named once in a
190
+ * QUARANTINE_KEPT line. A written path of the stopped commit without markers
191
+ * is captured; checking it costs two spawns per round (store, read).
192
+ */
193
+ async function splitMarkerLeftovers(input, pickPaths, leftovers, deps) {
194
+ const pending = [];
195
+ for (const path of writesDuringStoppedRebase(await deps.git.status())) {
196
+ if (leftovers.has(path)) {
197
+ continue;
198
+ }
199
+ if (pickPaths.includes(path)) {
200
+ const blobId = await deps.writer.storeBlob(path);
201
+ const content = await deps.git.readBlob(blobId);
202
+ if (findContentViolation({ path, content }, [])?.rule === 'CONFLICT_MARKERS') {
203
+ leftovers.add(path);
204
+ deps.logger.log({
205
+ level: 'WARN',
206
+ code: 'QUARANTINE_KEPT',
207
+ message: `${path} holds git's conflict markers from enmetu's stopped rebase but is no longer unmerged (a git command wrote the index meanwhile), so git rebase --abort resets it instead of enmetu writing the markers back; the version on disk is blob ${blobId} (git cat-file -p ${blobId}), kept until git gc prunes it`,
208
+ cycleId: input.cycleId,
209
+ });
210
+ continue;
211
+ }
212
+ }
213
+ pending.push(path);
214
+ }
215
+ return pending;
216
+ }
217
+ /** The paths the stopped pick changes (REBASE_HEAD^..REBASE_HEAD); none when either commit is missing. */
218
+ async function stoppedCommitPaths(deps) {
219
+ const pick = await deps.git.resolveCommit('REBASE_HEAD');
220
+ const parent = await deps.git.resolveCommit('REBASE_HEAD^');
221
+ return pick === null || parent === null
222
+ ? []
223
+ : deps.git.changedPaths(parent, pick);
224
+ }
225
+ /**
226
+ * The verification's checks, read without halting: no operation in progress, nothing
227
+ * unmerged, HEAD back at origHead. Returns the failure's detail, or null when
228
+ * all three hold. It records nothing, so a caller can ask who started the
229
+ * rebase before it decides on a halt (design 13.2).
230
+ */
231
+ export async function rebaseAbortFailure(input, deps) {
232
+ return rebaseEndFailure({ head: input.origHead, branchRef: null }, deps);
233
+ }
234
+ /**
235
+ * Step 2's checks (design 14.4): no operation, nothing unmerged, HEAD at
236
+ * expected.head, and HEAD naming expected.branchRef when it is set, so a HEAD
237
+ * left detached by a switch that never ran is not taken for an ended rebase.
238
+ */
239
+ async function rebaseEndFailure(expected, deps) {
240
+ const state = await deps.git.repositoryState();
241
+ const unmerged = await deps.git.hasUnmergedPaths();
242
+ const head = await deps.git.headCommit();
243
+ const onBranch = expected.branchRef === null || state.branchRef === expected.branchRef;
244
+ if (state.operation === null &&
245
+ !unmerged &&
246
+ head === expected.head &&
247
+ onBranch) {
248
+ return null;
249
+ }
250
+ const named = expected.branchRef === null
251
+ ? ''
252
+ : `, HEAD names ${state.branchRef ?? 'no branch'} (expected ${expected.branchRef})`;
253
+ return `operation ${state.operation ?? 'none'}, unmerged paths ${unmerged ? 'present' : 'none'}, HEAD ${head ?? 'unborn'} (expected ${expected.head})${named}`;
254
+ }
255
+ /**
256
+ * Halts REBASE_ABORT_UNVERIFIED and keeps the journal and any memento, which
257
+ * are what a person needs to finish by hand.
258
+ */
259
+ export async function recordAbortUnverified(input, failure, deps) {
260
+ await recordHalt({
261
+ code: 'REBASE_ABORT_UNVERIFIED',
262
+ message: `enmetu's rebase of ${input.origHead} onto ${input.onto} could not be confirmed aborted (${failure}); the rebase journal and any held paths are kept`,
263
+ cycleId: input.cycleId,
264
+ }, deps);
265
+ }
266
+ /**
267
+ * Step 0b (Decision 105). The capture reads the worktree column, and a
268
+ * person's `git add` leaves it clean like the rebase's own staging, so a
269
+ * staged write is not captured and `git rebase --abort` resets it. Restoring
270
+ * it would need telling it from the stopped pick's merge result, which an
271
+ * upstream rename stages outside the pick's paths (probe C2), so it is named
272
+ * instead: the person's `git add` wrote the blob, and the line carries its id.
273
+ * The pick's own paths are skipped (their entries are the merge's result), and
274
+ * so are captured paths, unmerged paths and staged deletions (no stage-0 blob).
275
+ * So is an entry whose blob is orig-head's version of the path: the abort
276
+ * resets it to that same blob, which is in enmetu's own history, so nothing
277
+ * is lost. A refused `git merge` that started from orig-head leaves exactly
278
+ * such entries when its restore_state (`git read-tree --reset -u <orig-head>`)
279
+ * runs during the stop (design 13.11, Decision 115).
280
+ */
281
+ async function nameStagedWrites(input, captured, pickPaths, deps) {
282
+ const staged = await deps.git.stagedPaths();
283
+ if (staged.length === 0) {
284
+ return;
285
+ }
286
+ for (const path of staged) {
287
+ if (pickPaths.includes(path) || captured.includes(path)) {
288
+ continue;
289
+ }
290
+ const blobId = await deps.git.entryBlobId(path, null);
291
+ if (blobId === null ||
292
+ blobId === (await deps.git.entryBlobId(path, input.origHead))) {
293
+ continue;
294
+ }
295
+ deps.logger.log({
296
+ level: 'WARN',
297
+ code: 'STAGED_RESET',
298
+ message: `${path} was staged while enmetu's rebase was stopped, and git rebase --abort resets it; the staged version is blob ${blobId} (git cat-file -p ${blobId}), kept until git gc prunes it`,
299
+ cycleId: input.cycleId,
300
+ });
301
+ }
302
+ }
@@ -0,0 +1,13 @@
1
+ import type { IFileSystem } from '../contracts/IFileSystem.js';
2
+ import type { IOperationJournal, RebaseJournal } from '../contracts/IOperationJournal.js';
3
+ export declare const JOURNAL_FILE_NAME = "rebase-journal.json";
4
+ /** Service: <stateDir>/rebase-journal.json, written before every rebase (Decision 14). */
5
+ export declare class OperationJournal implements IOperationJournal {
6
+ private readonly fs;
7
+ private readonly stateDir;
8
+ constructor(fs: IFileSystem, stateDir: string);
9
+ read(): Promise<RebaseJournal | null>;
10
+ begin(entry: RebaseJournal): Promise<void>;
11
+ clear(): Promise<void>;
12
+ private path;
13
+ }
@@ -0,0 +1,37 @@
1
+ import { join } from 'node:path';
2
+ import { parseRebaseJournal } from '../core/state/stateGuards.js';
3
+ export const JOURNAL_FILE_NAME = 'rebase-journal.json';
4
+ /** Service: <stateDir>/rebase-journal.json, written before every rebase (Decision 14). */
5
+ export class OperationJournal {
6
+ fs;
7
+ stateDir;
8
+ constructor(fs, stateDir) {
9
+ this.fs = fs;
10
+ this.stateDir = stateDir;
11
+ }
12
+ async read() {
13
+ const text = await this.fs.readText(this.path());
14
+ // Unparseable gives null: recovery never aborts a rebase it cannot prove
15
+ // is its own, and the preflight then refuses to start, so a person decides.
16
+ return text === null ? null : parseRebaseJournal(text);
17
+ }
18
+ async begin(entry) {
19
+ // Built field by field in interface order, so the file is byte-stable.
20
+ const persisted = {
21
+ origHead: entry.origHead,
22
+ onto: entry.onto,
23
+ cycleId: entry.cycleId,
24
+ at: entry.at,
25
+ };
26
+ await this.fs.writeAtomic(this.path(), `${JSON.stringify(persisted)}\n`, {
27
+ executable: false,
28
+ tempDir: this.stateDir,
29
+ });
30
+ }
31
+ async clear() {
32
+ await this.fs.remove(this.path());
33
+ }
34
+ path() {
35
+ return join(this.stateDir, JOURNAL_FILE_NAME);
36
+ }
37
+ }
@@ -0,0 +1,48 @@
1
+ import type { IClock } from '../contracts/IClock.js';
2
+ import type { RecoveryResult } from '../contracts/IDaemon.js';
3
+ import type { IGitIndexLock } from '../contracts/IGitIndexLock.js';
4
+ import type { IGitReader } from '../contracts/IGitReader.js';
5
+ import type { IGitWriter } from '../contracts/IGitWriter.js';
6
+ import type { IHaltStore } from '../contracts/IHaltStore.js';
7
+ import type { ILogger } from '../contracts/ILogger.js';
8
+ import type { IOperationJournal } from '../contracts/IOperationJournal.js';
9
+ import type { IPathQuarantine } from '../contracts/IPathQuarantine.js';
10
+ import type { IOrphanedCommitRecorder } from '../contracts/IStateRecorder.js';
11
+ export type { RecoveryResult };
12
+ export interface RecoveryInput {
13
+ /** Always 'recovery'. */
14
+ readonly cycleId: string;
15
+ }
16
+ export interface RecoveryDeps {
17
+ readonly git: IGitReader;
18
+ readonly writer: IGitWriter;
19
+ readonly journal: IOperationJournal;
20
+ readonly quarantine: IPathQuarantine;
21
+ readonly halt: IHaltStore;
22
+ readonly logger: ILogger;
23
+ readonly clock: IClock;
24
+ /** Finishes or undoes an index.lock left by a crashed enmetu commit, step 0 (design 12.7). */
25
+ readonly indexLock: IGitIndexLock;
26
+ /** Passed to the rebase abort, which reports each commit it orphans (design 14.15). */
27
+ readonly orphans?: IOrphanedCommitRecorder | undefined;
28
+ }
29
+ /**
30
+ * Controller: startup recovery after a crash (design 1.14).
31
+ *
32
+ * 0. indexLock.releaseStale(git.headCommit()); released adds
33
+ * INDEX_LOCK_RELEASED, its message saying whether the real index was kept
34
+ * (the crashed commit is on the branch) or restored (design 12.7, Decision 81).
35
+ * 1. journal.read(); none: go to 4.
36
+ * 2. A rebase in progress whose orig-head AND onto equal the journal's is enmetu's
37
+ * unless HEAD's newest start entry names another owner (rebaseOwner 'other',
38
+ * Decision 104: clear the journal, STALE_JOURNAL_CLEARED, leave it):
39
+ * abortEnmetuRebase (capture, name staged writes, abort, verify; a refused capture
40
+ * or an unverified abort halts REBASE_ABORT_UNVERIFIED). No rebase in progress:
41
+ * STALE_JOURNAL_CLEARED. Any other rebase is a person's: clear the journal, leave it.
42
+ * 3. A verified abort has cleared the journal (REBASE_ABORTED); a rebase
43
+ * ended at a branch a commit moved meanwhile is recorded with that tip
44
+ * (design 14.4, Decision 122).
45
+ * 4. quarantine.restore(): restored adds QUARANTINE_RESTORED; failed halts QUARANTINE_RESTORE_FAILED.
46
+ * Each action is logged RECOVERED once, here, with the recovery cycle id.
47
+ */
48
+ export declare function recoveryController(input: RecoveryInput, deps: RecoveryDeps): Promise<RecoveryResult>;
@@ -0,0 +1,112 @@
1
+ import { withCycleId } from '../core/errors/withCycleId.js';
2
+ import { rebaseOwner, rebaseReflogAction } from '../core/git/rebaseOwnership.js';
3
+ import { logRestoreWarnings } from '../core/quarantine/restoreWarnings.js';
4
+ import { recordHalt } from '../halt/recordHalt.js';
5
+ import { abortEnmetuRebase } from './enmetuRebaseAbort.js';
6
+ /**
7
+ * Controller: startup recovery after a crash (design 1.14).
8
+ *
9
+ * 0. indexLock.releaseStale(git.headCommit()); released adds
10
+ * INDEX_LOCK_RELEASED, its message saying whether the real index was kept
11
+ * (the crashed commit is on the branch) or restored (design 12.7, Decision 81).
12
+ * 1. journal.read(); none: go to 4.
13
+ * 2. A rebase in progress whose orig-head AND onto equal the journal's is enmetu's
14
+ * unless HEAD's newest start entry names another owner (rebaseOwner 'other',
15
+ * Decision 104: clear the journal, STALE_JOURNAL_CLEARED, leave it):
16
+ * abortEnmetuRebase (capture, name staged writes, abort, verify; a refused capture
17
+ * or an unverified abort halts REBASE_ABORT_UNVERIFIED). No rebase in progress:
18
+ * STALE_JOURNAL_CLEARED. Any other rebase is a person's: clear the journal, leave it.
19
+ * 3. A verified abort has cleared the journal (REBASE_ABORTED); a rebase
20
+ * ended at a branch a commit moved meanwhile is recorded with that tip
21
+ * (design 14.4, Decision 122).
22
+ * 4. quarantine.restore(): restored adds QUARANTINE_RESTORED; failed halts QUARANTINE_RESTORE_FAILED.
23
+ * Each action is logged RECOVERED once, here, with the recovery cycle id.
24
+ */
25
+ export async function recoveryController(input, deps) {
26
+ try {
27
+ return await recover(input, deps);
28
+ }
29
+ catch (error) {
30
+ throw withCycleId(error, input.cycleId);
31
+ }
32
+ }
33
+ async function recover(input, deps) {
34
+ const { cycleId } = input;
35
+ const actions = [];
36
+ const record = (action, message) => {
37
+ actions.push(action);
38
+ deps.logger.log({
39
+ level: 'INFO',
40
+ code: 'RECOVERED',
41
+ message: `${action}: ${message}`,
42
+ cycleId,
43
+ });
44
+ };
45
+ // --- Step 0: finish or undo a commit section a crash left under index.lock ---
46
+ const stale = await deps.indexLock.releaseStale(await deps.git.headCommit());
47
+ if (stale.kind === 'released') {
48
+ record('INDEX_LOCK_RELEASED', stale.index === 'kept'
49
+ ? 'removed the index.lock of a crashed enmetu commit; the commit is on the branch and the real index matches it'
50
+ : 'removed the index.lock of a crashed enmetu commit; HEAD never moved to it, so the real index is back to what it was before');
51
+ }
52
+ // --- Step 1: a journal means a rebase was started and not seen to finish ---
53
+ const journal = await deps.journal.read();
54
+ if (journal !== null) {
55
+ // --- Step 2: abort the rebase only when it is provably enmetu's ---
56
+ const origHead = await deps.git.rebaseOrigHead();
57
+ const onto = await deps.git.rebaseOnto();
58
+ if (origHead === null && onto === null) {
59
+ await deps.journal.clear();
60
+ record('STALE_JOURNAL_CLEARED', `no rebase is in progress; the journal of cycle ${journal.cycleId} was stale`);
61
+ }
62
+ else if (origHead !== journal.origHead || onto !== journal.onto) {
63
+ // A person's rebase, started after enmetu's ended: never touched here;
64
+ // the preflight then refuses to start while it is in progress.
65
+ await deps.journal.clear();
66
+ record('STALE_JOURNAL_CLEARED', `the rebase in progress is not enmetu's (orig-head ${origHead ?? 'none'}, onto ${onto ?? 'none'}); left alone`);
67
+ }
68
+ else if (rebaseOwner(await deps.git.rebaseStart(), journal) === 'other') {
69
+ // Same orig-head and onto, but HEAD's newest start entry is not the
70
+ // journal's token: a person's rebase from the same HEAD onto the same
71
+ // upstream, or enmetu's own killed before its checkout (Decision 104).
72
+ await deps.journal.clear();
73
+ record('STALE_JOURNAL_CLEARED', `the rebase in progress has the orig-head and onto of cycle ${journal.cycleId}'s journal, but HEAD's newest start entry is not that attempt's; left alone`);
74
+ }
75
+ else {
76
+ // Read while the rebase state exists, to name the branch should it have moved.
77
+ const branchRef = await deps.git.rebaseHeadName();
78
+ const abort = await abortEnmetuRebase({
79
+ cycleId,
80
+ origHead: journal.origHead,
81
+ onto: journal.onto,
82
+ reflogAction: rebaseReflogAction(journal),
83
+ }, deps);
84
+ if (abort.verdict === 'unverified') {
85
+ return { kind: 'halted', code: 'REBASE_ABORT_UNVERIFIED' };
86
+ }
87
+ // --- Step 3: the verified abort cleared the journal ---
88
+ record('REBASE_ABORTED', abort.branchMovedTo === null
89
+ ? `aborted enmetu's interrupted rebase onto ${journal.onto}; HEAD is back at ${journal.origHead}`
90
+ : `ended enmetu's interrupted rebase onto ${journal.onto} at ${abort.branchMovedTo}, the tip a commit moved ${branchRef ?? 'the branch'} to while the rebase waited; orig-head ${journal.origHead} is not restored`);
91
+ }
92
+ }
93
+ // --- Step 4: restore paths a crash left held, only now that no rebase of enmetu's is in progress ---
94
+ const restore = await deps.quarantine.restore();
95
+ if (restore.kind === 'failed') {
96
+ await recordHalt({
97
+ code: 'QUARANTINE_RESTORE_FAILED',
98
+ message: `restoring the quarantine memento left by a crash failed (${restore.detail}); the memento is kept`,
99
+ cycleId,
100
+ }, deps);
101
+ return { kind: 'halted', code: 'QUARANTINE_RESTORE_FAILED' };
102
+ }
103
+ if (restore.kind === 'restored') {
104
+ logRestoreWarnings(restore, cycleId, deps.logger);
105
+ record('QUARANTINE_RESTORED', restore.count === 1
106
+ ? '1 held path restored'
107
+ : `${restore.count} held paths restored`);
108
+ }
109
+ return actions.length === 0
110
+ ? { kind: 'clean' }
111
+ : { kind: 'recovered', actions };
112
+ }
@@ -0,0 +1,18 @@
1
+ import type { IFileSystem } from '../contracts/IFileSystem.js';
2
+ import type { IStateStore, StateRecord } from '../contracts/IStateStore.js';
3
+ export declare const STATE_FILE_NAME = "state.json";
4
+ /**
5
+ * Service: <stateDir>/state.json (design 12.1). read() parses through
6
+ * parseStateRecord; write() serialises the fields in StateRecord order into
7
+ * one JSON line and replaces the file with writeAtomic (temp file in the state
8
+ * directory, then rename), so a reader sees the old record or the new one,
9
+ * never a mix, and the file never grows by appending.
10
+ */
11
+ export declare class StateFileStore implements IStateStore {
12
+ private readonly fs;
13
+ private readonly stateDir;
14
+ constructor(fs: IFileSystem, stateDir: string);
15
+ read(): Promise<StateRecord | null>;
16
+ write(record: StateRecord): Promise<void>;
17
+ private path;
18
+ }
@@ -0,0 +1,73 @@
1
+ import { join } from 'node:path';
2
+ import { parseStateRecord } from '../core/state/stateGuards.js';
3
+ export const STATE_FILE_NAME = 'state.json';
4
+ /**
5
+ * Service: <stateDir>/state.json (design 12.1). read() parses through
6
+ * parseStateRecord; write() serialises the fields in StateRecord order into
7
+ * one JSON line and replaces the file with writeAtomic (temp file in the state
8
+ * directory, then rename), so a reader sees the old record or the new one,
9
+ * never a mix, and the file never grows by appending.
10
+ */
11
+ export class StateFileStore {
12
+ fs;
13
+ stateDir;
14
+ constructor(fs, stateDir) {
15
+ this.fs = fs;
16
+ this.stateDir = stateDir;
17
+ }
18
+ async read() {
19
+ const text = await this.fs.readText(this.path());
20
+ return text === null ? null : parseStateRecord(text);
21
+ }
22
+ async write(record) {
23
+ await this.fs.writeAtomic(this.path(), `${serialise(record)}\n`, {
24
+ executable: false,
25
+ tempDir: this.stateDir,
26
+ });
27
+ }
28
+ path() {
29
+ return join(this.stateDir, STATE_FILE_NAME);
30
+ }
31
+ }
32
+ /**
33
+ * JSON.stringify keeps insertion order, so the record is rebuilt field by
34
+ * field: the bytes then depend on the values only, whichever object built them.
35
+ */
36
+ function serialise(record) {
37
+ const ordered = {
38
+ version: record.version,
39
+ instance: {
40
+ pid: record.instance.pid,
41
+ hostname: record.instance.hostname,
42
+ startedAt: record.instance.startedAt,
43
+ },
44
+ state: record.state,
45
+ reason: record.reason,
46
+ message: record.message,
47
+ since: record.since,
48
+ pushed: record.pushed,
49
+ unpushed: record.unpushed,
50
+ lastCycle: record.lastCycle === null
51
+ ? null
52
+ : {
53
+ cycleId: record.lastCycle.cycleId,
54
+ outcome: record.lastCycle.outcome,
55
+ at: record.lastCycle.at,
56
+ },
57
+ conflict: record.conflict === null
58
+ ? null
59
+ : { head: record.conflict.head, upstream: record.conflict.upstream },
60
+ history: record.history.map((transition) => ({
61
+ state: transition.state,
62
+ reason: transition.reason,
63
+ at: transition.at,
64
+ })),
65
+ orphaned: record.orphaned.map((orphan) => ({
66
+ commit: orphan.commit,
67
+ base: orphan.base,
68
+ subject: orphan.subject,
69
+ at: orphan.at,
70
+ })),
71
+ };
72
+ return JSON.stringify(ordered);
73
+ }
@@ -0,0 +1,26 @@
1
+ import type { IClock } from '../contracts/IClock.js';
2
+ import type { ILogger } from '../contracts/ILogger.js';
3
+ import type { IOrphanedCommitRecorder, IStateRecorder, OrphanedCommitFacts, StateEvent } from '../contracts/IStateRecorder.js';
4
+ import type { IStateStore } from '../contracts/IStateStore.js';
5
+ /**
6
+ * Service, the imperative shell around stateRecordStep (design 12.1): holds
7
+ * the current record (read from the store once, at the first event, so the
8
+ * history survives a restart), applies each event with the clock's time and
9
+ * the history cap, and writes the result. A failed read means no record;
10
+ * a failed write logs STATE_WRITE_FAILED once until a write succeeds again
11
+ * and is never thrown, so state recording cannot stop syncing.
12
+ */
13
+ export declare class StateRecorder implements IStateRecorder, IOrphanedCommitRecorder {
14
+ private readonly store;
15
+ private readonly clock;
16
+ private readonly logger;
17
+ private readonly historyCap;
18
+ private current;
19
+ private loaded;
20
+ private writeFailing;
21
+ constructor(store: IStateStore, clock: IClock, logger: ILogger, historyCap: number);
22
+ record(event: StateEvent): Promise<void>;
23
+ /** The rebase abort's view of this recorder (design 14.15); never throws, like record. */
24
+ orphaned(facts: OrphanedCommitFacts): Promise<void>;
25
+ private previous;
26
+ }