@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,150 @@
1
+ import { join } from 'node:path';
2
+ /** At most this many repaired paths are named in the log line; the count is always exact. */
3
+ export const MAX_REPAIRED_PATHS_LOGGED = 20;
4
+ const SHORT_SHA_LENGTH = 7;
5
+ /**
6
+ * Controller: sets back index entries that a person's git command wrote back
7
+ * from before enmetu's last move of HEAD, its commit or its pull (design
8
+ * 12.7, Decision 98). A command that read the real index before the move and
9
+ * writes it after (measured for git merge against enmetu's commit section;
10
+ * seen in the stress scenario against enmetu's rebase) leaves HEAD at
11
+ * lastMove.to and lastMove.from's entries in the index, which would revert
12
+ * the move in the person's next commit.
13
+ *
14
+ * 1. HEAD differs from lastMove.to: moved.
15
+ * 2. Candidates: git.stagedPaths() (index against HEAD), in git's order. A
16
+ * path is stale when its index entry equals lastMove.from's (both absent
17
+ * for a path the move added) while the working tree does not (absent, or
18
+ * a regular file whose filtered blob is not from's; a link or a directory
19
+ * is never stale). A path the move did not change has from's entry in
20
+ * HEAD, so it differs from HEAD only with another entry and never matches.
21
+ * Reverted: index entry and working tree both from's (design 13.2,
22
+ * Decision 108). Neither: none. Only reverted: STAGED_REVERT once, nothing
23
+ * written, reverted.
24
+ * 3. Prepare: fs.copy(realIndex, commit index), so every other entry, the
25
+ * person's staged selection included, stays unchanged; commitIndex.writer
26
+ * .unstage(stale) sets exactly those entries to HEAD's.
27
+ * 4. Critical section, shaped like the commit's (Decision 81): indexLock.acquire
28
+ * (false: busy); an operation in progress or the real index's identity
29
+ * differing from before: release, busy; HEAD moved: release, moved;
30
+ * indexLock.install(commit index); release on every path.
31
+ * 5. INDEX_REPAIRED once, naming the paths, then STAGED_REVERT for reverted
32
+ * paths; repaired. Busy or moved names nothing, so the next cycle does.
33
+ */
34
+ export async function repairStaleIndexController(input, deps) {
35
+ const { cycleId, lastMove, before } = input;
36
+ // --- Step 1: only while HEAD is still where enmetu moved it ---
37
+ if ((await deps.git.headCommit()) !== lastMove.to) {
38
+ return { kind: 'moved' };
39
+ }
40
+ // --- Step 2: staged paths with the stale or the reverted signature ---
41
+ const stale = [];
42
+ const reverted = [];
43
+ for (const path of await deps.git.stagedPaths()) {
44
+ const signature = await classify(path, lastMove, deps);
45
+ if (signature === 'stale') {
46
+ stale.push(path);
47
+ }
48
+ else if (signature === 'reverted') {
49
+ reverted.push(path);
50
+ }
51
+ }
52
+ if (stale.length === 0) {
53
+ if (reverted.length === 0) {
54
+ return { kind: 'none' };
55
+ }
56
+ logReverted(reverted, input, deps);
57
+ return { kind: 'reverted', paths: reverted };
58
+ }
59
+ // --- Step 3: the repaired index, prepared beside the real one ---
60
+ if (!(await deps.fs.copy(deps.settings.realIndex, deps.commitIndex.path))) {
61
+ // No real index: a stale write-back always leaves one, so something else happened.
62
+ return { kind: 'busy' };
63
+ }
64
+ await deps.commitIndex.writer.unstage(stale);
65
+ // --- Step 4: install it under git's own index.lock ---
66
+ const installed = await install(lastMove.to, before, deps);
67
+ if (installed !== 'installed') {
68
+ return { kind: installed };
69
+ }
70
+ // --- Step 5: one line per repair, then the reverted paths once it is installed ---
71
+ deps.logger.log({
72
+ level: 'WARN',
73
+ code: 'INDEX_REPAIRED',
74
+ message: `a git command wrote back the index from before enmetu moved HEAD to ${lastMove.to.slice(0, SHORT_SHA_LENGTH)}; ${stale.length === 1 ? '1 index entry' : `${stale.length} index entries`} set back to HEAD, which a commit would otherwise revert: ${namedPaths(stale)}`,
75
+ cycleId,
76
+ });
77
+ if (reverted.length > 0) {
78
+ logReverted(reverted, input, deps);
79
+ }
80
+ return { kind: 'repaired', paths: stale };
81
+ }
82
+ /**
83
+ * Each side is a blob id, or null where the path is absent; the index side
84
+ * must be from's. Stale: the working-tree side is not from's, so a person who
85
+ * keeps editing the file after the move still matches, which is why the
86
+ * working tree is compared with from, not with to (stress seed 11 of
87
+ * follow-up review 2). Reverted: the working tree is from's too, where both
88
+ * absent count equal (Decision 108): a merge's restore_state, or a deliberate
89
+ * revert (git checkout HEAD~ -- <path>), which is why it is named and never
90
+ * repaired. null: neither.
91
+ */
92
+ async function classify(path, lastMove, deps) {
93
+ const fromBlob = await deps.git.entryBlobId(path, lastMove.from);
94
+ if ((await deps.git.entryBlobId(path, null)) !== fromBlob) {
95
+ return null;
96
+ }
97
+ const facts = await deps.fs.facts(join(deps.settings.root, path));
98
+ if (facts === null) {
99
+ return fromBlob === null ? 'reverted' : 'stale';
100
+ }
101
+ if (facts.kind !== 'file') {
102
+ // A link, a directory or a special file: never judged, never touched.
103
+ return null;
104
+ }
105
+ return (await deps.git.worktreeBlobId(path)) === fromBlob
106
+ ? 'reverted'
107
+ : 'stale';
108
+ }
109
+ /**
110
+ * STAGED_REVERT (Decision 108): names enmetu's commit by its full id, so the
111
+ * remedy stays valid after a later commit of enmetu's records the revert.
112
+ */
113
+ function logReverted(paths, input, deps) {
114
+ const { to } = input.lastMove;
115
+ deps.logger.log({
116
+ level: 'WARN',
117
+ code: 'STAGED_REVERT',
118
+ message: `staged and on disk as before enmetu moved HEAD to ${to.slice(0, SHORT_SHA_LENGTH)}, as a git merge refused with "would be overwritten by merge" leaves them: ${namedPaths(paths)}; unless you reverted them yourself, git restore --source=${to} --staged --worktree -- <path> brings back the version enmetu committed`,
119
+ cycleId: input.cycleId,
120
+ });
121
+ }
122
+ /** At most MAX_REPAIRED_PATHS_LOGGED paths, then how many more. */
123
+ function namedPaths(paths) {
124
+ const named = paths.slice(0, MAX_REPAIRED_PATHS_LOGGED).join(', ');
125
+ const rest = paths.length - MAX_REPAIRED_PATHS_LOGGED;
126
+ return `${named}${rest > 0 ? ` and ${rest} more` : ''}`;
127
+ }
128
+ async function install(head, before, deps) {
129
+ // The lock names HEAD, so recovery after a crash in here keeps the
130
+ // installed index, which matches HEAD better than the stale one.
131
+ if (!(await deps.indexLock.acquire(head))) {
132
+ return 'busy';
133
+ }
134
+ try {
135
+ if ((await deps.git.repositoryState()).operation !== null) {
136
+ return 'busy';
137
+ }
138
+ if ((await deps.git.headCommit()) !== head) {
139
+ return 'moved';
140
+ }
141
+ if ((await deps.fs.identity(deps.settings.realIndex)) !== before) {
142
+ return 'busy';
143
+ }
144
+ await deps.indexLock.install(deps.commitIndex.path);
145
+ return 'installed';
146
+ }
147
+ finally {
148
+ await deps.indexLock.release();
149
+ }
150
+ }
@@ -0,0 +1,30 @@
1
+ import type { IGitReader } from '../contracts/IGitReader.js';
2
+ import type { IGitWriter } from '../contracts/IGitWriter.js';
3
+ import type { ILogger } from '../contracts/ILogger.js';
4
+ export interface StageGuardedInput {
5
+ readonly cycleId: string;
6
+ }
7
+ export interface StageGuardedDeps {
8
+ readonly git: IGitReader;
9
+ readonly writer: IGitWriter;
10
+ readonly logger: ILogger;
11
+ readonly skipJsonCheck: readonly string[];
12
+ }
13
+ export interface StageGuardedResult {
14
+ readonly staged: boolean;
15
+ }
16
+ /**
17
+ * Controller: stage everything, inspect the staged blobs with the shared
18
+ * content rules, unstage what is not fit to commit (F3, F10, F11, D11).
19
+ *
20
+ * 1. writer.stageAll(); partial logs STAGE_PARTIAL.
21
+ * 2. For each git.stagedFiles(MAX_SCAN_BYTES): findContentViolation.
22
+ * 3. Violations: one writer.unstage(paths) call, EXCLUDED logged per path.
23
+ * 4. staged = git.hasStagedChanges().
24
+ *
25
+ * Inspecting the staged blobs rather than the working tree means what is
26
+ * inspected is exactly what the next commit records (Decision 5). The
27
+ * controller raises no error of its own; git failures propagate unchanged to
28
+ * the cycle, which owns the busy mapping and threads the cycle id.
29
+ */
30
+ export declare function stageGuardedChanges(input: StageGuardedInput, deps: StageGuardedDeps): Promise<StageGuardedResult>;
@@ -0,0 +1,52 @@
1
+ import { findContentViolation, MAX_SCAN_BYTES, } from '../core/content/contentRules.js';
2
+ /**
3
+ * Controller: stage everything, inspect the staged blobs with the shared
4
+ * content rules, unstage what is not fit to commit (F3, F10, F11, D11).
5
+ *
6
+ * 1. writer.stageAll(); partial logs STAGE_PARTIAL.
7
+ * 2. For each git.stagedFiles(MAX_SCAN_BYTES): findContentViolation.
8
+ * 3. Violations: one writer.unstage(paths) call, EXCLUDED logged per path.
9
+ * 4. staged = git.hasStagedChanges().
10
+ *
11
+ * Inspecting the staged blobs rather than the working tree means what is
12
+ * inspected is exactly what the next commit records (Decision 5). The
13
+ * controller raises no error of its own; git failures propagate unchanged to
14
+ * the cycle, which owns the busy mapping and threads the cycle id.
15
+ */
16
+ export async function stageGuardedChanges(input, deps) {
17
+ // --- Step 1: stage the whole tree ---
18
+ const stage = await deps.writer.stageAll();
19
+ if (stage.partial) {
20
+ // One unreadable file is reported and skipped instead of blocking every commit.
21
+ deps.logger.log({
22
+ level: 'WARN',
23
+ code: 'STAGE_PARTIAL',
24
+ message: stage.detail,
25
+ cycleId: input.cycleId,
26
+ });
27
+ }
28
+ // --- Step 2: inspect one staged blob at a time ---
29
+ const violations = [];
30
+ for await (const file of deps.git.stagedFiles(MAX_SCAN_BYTES)) {
31
+ const violation = findContentViolation(file, deps.skipJsonCheck);
32
+ if (violation !== null) {
33
+ violations.push(violation);
34
+ }
35
+ }
36
+ // --- Step 3: unstage the violations in one call, in git's order ---
37
+ if (violations.length > 0) {
38
+ // reset also clears unmerged entries, which git add has already turned
39
+ // into stage-0 marker text; the bytes stay on disk for the person.
40
+ await deps.writer.unstage(violations.map((violation) => violation.path));
41
+ for (const violation of violations) {
42
+ deps.logger.log({
43
+ level: 'WARN',
44
+ code: 'EXCLUDED',
45
+ message: `${violation.path}: ${violation.rule} (${violation.detail})`,
46
+ cycleId: input.cycleId,
47
+ });
48
+ }
49
+ }
50
+ // --- Step 4: report whether anything is left to commit ---
51
+ return { staged: await deps.git.hasStagedChanges() };
52
+ }
@@ -0,0 +1,7 @@
1
+ export interface TimerHandle {
2
+ cancel(): void;
3
+ }
4
+ export interface IClock {
5
+ now(): number;
6
+ after(ms: number, callback: () => void): TimerHandle;
7
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,26 @@
1
+ import type { ExitCode } from './exitCode.js';
2
+ import type { Upstream } from './gitTypes.js';
3
+ import type { HaltCode } from './IHaltStore.js';
4
+ export type RecoveryAction = 'REBASE_ABORTED' | 'STALE_JOURNAL_CLEARED' | 'QUARANTINE_RESTORED'
5
+ /** An index.lock left by a crashed enmetu's commit (design 12.7). */
6
+ | 'INDEX_LOCK_RELEASED';
7
+ export type RecoveryResult = {
8
+ readonly kind: 'clean';
9
+ } | {
10
+ readonly kind: 'recovered';
11
+ readonly actions: readonly RecoveryAction[];
12
+ } | {
13
+ readonly kind: 'halted';
14
+ readonly code: HaltCode;
15
+ };
16
+ /** recoveryController with its deps bound (a closure built by the composition root). */
17
+ export interface IRecovery {
18
+ recover(): Promise<RecoveryResult>;
19
+ }
20
+ /** repositoryPreflight with its deps bound; throws PreflightError. */
21
+ export interface IPreflight {
22
+ check(): Promise<Upstream>;
23
+ }
24
+ export interface IDaemon {
25
+ run(): Promise<ExitCode>;
26
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,50 @@
1
+ export interface FileFacts {
2
+ readonly kind: 'file' | 'directory' | 'symlink' | 'other';
3
+ readonly executable: boolean;
4
+ }
5
+ export interface WriteOptions {
6
+ readonly executable: boolean;
7
+ /**
8
+ * Where the temp file is created before the rename. A rename across file
9
+ * systems (EXDEV) retries with the temp file in the target's directory.
10
+ */
11
+ readonly tempDir: string;
12
+ }
13
+ export interface IFileSystem {
14
+ /** null when absent. */
15
+ readText(path: string): Promise<string | null>;
16
+ /** Temp file, then rename over the target; creates parent directories. */
17
+ writeAtomic(path: string, content: string | Buffer, options: WriteOptions): Promise<void>;
18
+ /** Complete content or nothing: temp file, link(2) to the target, unlink temp; creates parent directories. false when the target exists. */
19
+ createExclusive(path: string, content: string): Promise<boolean>;
20
+ /** rename(2); false when the source does not exist; creates the target's parent directory when it is missing. Exactly one of two concurrent renames of one file succeeds. */
21
+ rename(from: string, to: string): Promise<boolean>;
22
+ /** No error when absent. */
23
+ remove(path: string): Promise<void>;
24
+ /** lstat; null when absent. */
25
+ facts(path: string): Promise<FileFacts | null>;
26
+ /**
27
+ * Copies a file's bytes over the target in one open (copy-on-write where
28
+ * the file system offers it); false when the source does not exist. The
29
+ * copy's modification time is the source's, rounded down to the whole
30
+ * second and read before the copy, so never later than the copied bytes:
31
+ * git judges an index entry "racily clean" against its index file's own
32
+ * modification time, in whole seconds, and a fresh time on a copied index
33
+ * would hide a same-size edit made in the second the source was written.
34
+ */
35
+ copy(from: string, to: string): Promise<boolean>;
36
+ /**
37
+ * An opaque identity of the file's current version (stat: device, inode,
38
+ * size, modification and change time); null when absent. Two equal values
39
+ * mean no rename replaced the file and no write changed it in between (design 12.7).
40
+ */
41
+ identity(path: string): Promise<string | null>;
42
+ /** link(2): a second name for the same file; false when the source does not exist. Fails when the target exists. */
43
+ link(existing: string, target: string): Promise<boolean>;
44
+ /**
45
+ * Whether two existing paths are on one file system (stat's device), so
46
+ * link(2) and rename(2) between them can work; throws when either is
47
+ * absent (design 13.11, Decision 117).
48
+ */
49
+ sameFileSystem(left: string, right: string): Promise<boolean>;
50
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,60 @@
1
+ /**
2
+ * The repository hooks enmetu runs around its own commit when commitHooks is
3
+ * true (design 12.7). The message hooks (prepare-commit-msg, commit-msg) do
4
+ * not run, so no hook can put a name into enmetu's message (Decision 80).
5
+ */
6
+ export type CommitHookName = 'pre-commit' | 'post-commit' | 'post-index-change';
7
+ export type HookRun = {
8
+ readonly kind: 'passed';
9
+ }
10
+ /**
11
+ * The hook exited non-zero, outlived hookTimeoutMs or was ended by a signal;
12
+ * detail is its stderr, or the timeout or signal.
13
+ */
14
+ | {
15
+ readonly kind: 'rejected';
16
+ readonly detail: string;
17
+ };
18
+ export interface CommitTreeInput {
19
+ readonly tree: string;
20
+ /** HEAD at the time the commit index was seeded: the only parent, never MERGE_HEAD. */
21
+ readonly parent: string;
22
+ /** The whole message, passed on stdin. */
23
+ readonly message: string;
24
+ /** -S when true, --no-gpg-sign when false: commit-tree does not read commit.gpgSign itself. */
25
+ readonly sign: boolean;
26
+ }
27
+ /**
28
+ * Runs one repository hook through `git hook run --ignore-missing`, which
29
+ * honours core.hooksPath; a missing hook passes. The hook gets its own
30
+ * timeout (hookTimeoutMs) and its output is truncated, never a failure. The
31
+ * facade it is called on decides GIT_INDEX_FILE: the commit index for
32
+ * pre-commit, the real index for post-commit and post-index-change.
33
+ */
34
+ export interface IGitHookRunner {
35
+ runHook(name: CommitHookName, args: readonly string[]): Promise<HookRun>;
36
+ /**
37
+ * Whether `git hook run` would find the hook: the path git itself resolves
38
+ * (`rev-parse --git-path hooks/<name>`, which honours core.hooksPath) is an
39
+ * executable file, or a symbolic link, which counts too, so a doubt
40
+ * costs a redundant check rather than a missed hook.
41
+ */
42
+ hookExists(name: CommitHookName): Promise<boolean>;
43
+ }
44
+ /**
45
+ * Plumbing for enmetu's own commits on the commit index
46
+ * (<stateDir>/commit-index): nothing here touches the real index, HEAD or a
47
+ * ref, and nothing here runs a hook but runHook.
48
+ */
49
+ export interface IGitCommitter extends IGitHookRunner {
50
+ /**
51
+ * git read-tree --reset <commit>, hookless: the commit index holds that
52
+ * commit's tree, stat data kept where content matches. --reset, not -m,
53
+ * because -m refuses an index with unmerged entries (a conflicted stash pop).
54
+ */
55
+ seedFrom(commit: string): Promise<void>;
56
+ /** git write-tree of the commit index, hookless. */
57
+ writeTree(): Promise<string>;
58
+ /** git commit-tree; runs no hook, concludes nothing, moves no ref. */
59
+ commitTree(input: CommitTreeInput): Promise<string>;
60
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,5 @@
1
+ /** The one git question the hook installer needs; it cannot write configuration through it. */
2
+ export interface IGitConfigReader {
3
+ /** null when unset at every scope. */
4
+ configValue(key: string): Promise<string | null>;
5
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,44 @@
1
+ /** What startup found of an index.lock a crashed enmetu left (design 12.7). */
2
+ export type StaleIndexLock =
3
+ /** No index.lock, one that is not enmetu's, or one whose enmetu is still alive: nothing touched. */
4
+ {
5
+ readonly kind: 'none';
6
+ }
7
+ /**
8
+ * Removed. kept: HEAD is the commit the lock names, so the installed index
9
+ * already matches it. restored: HEAD is not, so index.before went back over
10
+ * the real index.
11
+ */
12
+ | {
13
+ readonly kind: 'released';
14
+ readonly index: 'kept' | 'restored';
15
+ };
16
+ /**
17
+ * git's own lockfile protocol on <gitDir>/index.lock, held by enmetu across
18
+ * the index install and the branch swap of its commit (design 12.7), so no
19
+ * git command can write the real index, start a merge or commit a stale index
20
+ * inside that window. The lock file's content names enmetu, its pid and the
21
+ * commit it is about to install, so a lock left by a crashed enmetu can be told
22
+ * from a person's and finished or undone at the next start.
23
+ */
24
+ export interface IGitIndexLock {
25
+ /** createExclusive(index.lock, 'enmetu index lock, pid <pid>, commit <commit>\n'); false when another process holds it. */
26
+ acquire(commit: string): Promise<boolean>;
27
+ /**
28
+ * Under the lock: hard-links <gitDir>/index to <stateDir>/index.before (the
29
+ * person's current index), then renames the prepared index over
30
+ * <gitDir>/index. A reader sees the old index or the new one, never none.
31
+ */
32
+ install(preparedIndex: string): Promise<void>;
33
+ /** Under the lock, after a failed swap: renames index.before back over <gitDir>/index. */
34
+ restore(): Promise<void>;
35
+ /** Removes index.before, then the lock this instance holds; the index stays unchanged. */
36
+ release(): Promise<void>;
37
+ /**
38
+ * Startup only, with the current HEAD: an index.lock with enmetu's content
39
+ * and a dead pid is finished (HEAD is the named commit: index.before removed)
40
+ * or undone (index.before renamed back), then removed. Never touches any other
41
+ * index.lock.
42
+ */
43
+ releaseStale(head: string | null): Promise<StaleIndexLock>;
44
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,81 @@
1
+ import type { Divergence, HeadMove, RebaseStart, RemoteHead, RepositoryState, StagedFile, StatusEntry, Upstream } from './gitTypes.js';
2
+ import type { IGitConfigReader } from './IGitConfigReader.js';
3
+ export interface IGitReader extends IGitConfigReader {
4
+ repositoryState(): Promise<RepositoryState>;
5
+ /** null when HEAD is unborn. */
6
+ headCommit(): Promise<string | null>;
7
+ hasCommitterIdentity(): Promise<boolean>;
8
+ /** null when the branch has no upstream. */
9
+ upstream(branchRef: string): Promise<Upstream | null>;
10
+ /** Whole tree, or only the given root-relative paths. */
11
+ status(paths?: readonly string[] | undefined): Promise<readonly StatusEntry[]>;
12
+ /**
13
+ * Staged regular files, read one cat-file batch chunk at a time, so at most
14
+ * one chunk of content is held while the consumer inspects it.
15
+ */
16
+ stagedFiles(maxBytes: number): AsyncIterable<StagedFile>;
17
+ hasStagedChanges(): Promise<boolean>;
18
+ /** Root-relative paths whose index entry differs from HEAD's, a rename being a delete plus an add (Decision 98). */
19
+ stagedPaths(): Promise<readonly string[]>;
20
+ remoteHead(remote: string, remoteRef: string): Promise<RemoteHead>;
21
+ /** null when the ref does not exist. */
22
+ resolveCommit(ref: string): Promise<string | null>;
23
+ isAncestor(ancestor: string, descendant: string): Promise<boolean>;
24
+ divergence(upstreamSha: string): Promise<Divergence>;
25
+ hasUnmergedPaths(): Promise<boolean>;
26
+ /** orig-head of a rebase in progress, null when none is in progress. */
27
+ rebaseOrigHead(): Promise<string | null>;
28
+ /** onto of a rebase in progress, null when none is in progress. */
29
+ rebaseOnto(): Promise<string | null>;
30
+ /**
31
+ * The branch a rebase in progress returns to: its head-name file
32
+ * (`refs/heads/main`), which `git rebase --abort` resets to orig-head with
33
+ * no old-value check. null when no rebase is in progress or it started on a
34
+ * detached HEAD (head-name `detached HEAD`). Design 14.4, Decision 122.
35
+ */
36
+ rebaseHeadName(): Promise<string | null>;
37
+ /**
38
+ * HEAD's newest "(start)" reflog entry: `git reflog exists HEAD` (exit 1:
39
+ * unlogged), then `git log -g -1 --format=%gs --grep-reflog=" (start): "
40
+ * HEAD` (empty: none). rebaseOwner (src/core/git/rebaseOwnership.ts) turns
41
+ * it and the journal into the owner of the rebase in progress (design
42
+ * 13.2, Decision 104).
43
+ */
44
+ rebaseStart(): Promise<RebaseStart>;
45
+ /**
46
+ * HEAD's move since the rebase run under reflogAction (its
47
+ * GIT_REFLOG_ACTION) last moved it, read from HEAD's newest reflog entries
48
+ * (`git log -g -n <bound> --format="%H %gs" HEAD`). null when the newest
49
+ * entry is the rebase's own (`<reflogAction> (`), when none of the read
50
+ * entries is, or when HEAD has no reflog (design 14.14, Decision 124).
51
+ */
52
+ headMovedSince(reflogAction: string): Promise<HeadMove | null>;
53
+ /** The commit's subject line (`git log -1 --format=%s <commit>`), design 14.15. */
54
+ commitSubject(commit: string): Promise<string>;
55
+ /**
56
+ * Whether an orphaned commit was dealt with (design 14.15): a local branch
57
+ * contains it (`git for-each-ref --count=1 --contains <commit> refs/heads/`,
58
+ * after a merge or a branch made at it), or a commit on a local branch
59
+ * committed since `since` records picking it (`git log --branches -F
60
+ * --grep="(cherry picked from commit <commit>)"`, which `git cherry-pick -x`
61
+ * writes, also after a resolved conflict). The commit must resolve.
62
+ */
63
+ onLocalBranch(commit: string, since: string): Promise<boolean>;
64
+ readBlob(blobId: string): Promise<Buffer>;
65
+ /** Blob id the file's current bytes would get, without filters, without writing. */
66
+ hashFile(path: string): Promise<string>;
67
+ /** Blob id `git add` would stage for the file's current bytes, filters applied, without writing an object. */
68
+ worktreeBlobId(path: string): Promise<string>;
69
+ /** Blob id of the path in a commit's tree, or in the index's stage-0 entry when commit is null; null when there is none. */
70
+ entryBlobId(path: string, commit: string | null): Promise<string | null>;
71
+ /**
72
+ * git merge-file -p of three files given by absolute path: the merged bytes
73
+ * when the three-way merge is clean, null when it conflicts or git cannot
74
+ * merge them (binary). Writes nothing, no conflict marker ever leaves it.
75
+ */
76
+ mergeFile(current: string, base: string, other: string): Promise<Buffer | null>;
77
+ /** The best common ancestor of two commits; null when they share none (design 12.3). */
78
+ mergeBase(left: string, right: string): Promise<string | null>;
79
+ /** Root-relative paths that differ between two commits, a rename being a delete plus an add, in git's order. */
80
+ changedPaths(from: string, to: string): Promise<readonly string[]>;
81
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,49 @@
1
+ export interface GitRunOptions {
2
+ /** Written to git's stdin, then stdin is closed. */
3
+ readonly stdin?: string | undefined;
4
+ /**
5
+ * Replaces the adapter's per-command timeout for this call: a repository
6
+ * hook's own budget (design 12.7). The call runs git, leading its own
7
+ * process group and the timeout ends the whole group (SIGTERM, then SIGKILL
8
+ * after 2 s), so a hook git started never outlives it.
9
+ */
10
+ readonly timeoutMs?: number | undefined;
11
+ /**
12
+ * Keeps at most this many bytes of stdout and of stderr and drops the rest
13
+ * instead of failing (a repository hook's output, design 12.7).
14
+ */
15
+ readonly outputCapBytes?: number | undefined;
16
+ /**
17
+ * Ends the call like its timeout once aborted (the whole process group,
18
+ * SIGTERM, then SIGKILL after 2 s), and the call throws GIT_ABORTED; an
19
+ * already aborted signal spawns nothing. A stop request ends a running
20
+ * repository hook this way (design 12.7, Decision 92).
21
+ */
22
+ readonly abort?: AbortSignal | undefined;
23
+ /**
24
+ * Sets GIT_REFLOG_ACTION for this call only, so the entries git writes to
25
+ * HEAD's reflog carry this action instead of the command's name: enmetu's
26
+ * rebase writes "enmetu <cycleId> <at> (start): checkout <sha>", the
27
+ * attempt's own token, which tells it from a person's rebase with the same
28
+ * orig-head and onto and from an earlier attempt of enmetu's (design 13.2,
29
+ * Decision 104).
30
+ */
31
+ readonly reflogAction?: string | undefined;
32
+ }
33
+ export interface GitRunResult {
34
+ readonly exitCode: number;
35
+ readonly stdout: Buffer;
36
+ readonly stderr: string;
37
+ }
38
+ /**
39
+ * Runs the git binary with an argument array (execFile, never a shell).
40
+ * Never throws for a non-zero exit code: the caller interprets it.
41
+ * Throws GitCommandError GIT_NOT_FOUND when the binary cannot be spawned and
42
+ * GIT_TIMEOUT when the per-command timeout elapses (git is killed with SIGTERM),
43
+ * and GIT_ABORTED when options.abort ends the call.
44
+ */
45
+ export interface IGitRunner {
46
+ run(args: readonly string[], options?: GitRunOptions): Promise<GitRunResult>;
47
+ }
48
+ /** Builds a runner for a working directory; the locator needs one before the root is known. */
49
+ export type GitRunnerFactory = (cwd: string) => IGitRunner;
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,39 @@
1
+ import type { FetchResult, PushResult, RebaseResult, StageResult } from './gitTypes.js';
2
+ export interface IGitWriter {
3
+ stageAll(): Promise<StageResult>;
4
+ unstage(paths: readonly string[]): Promise<void>;
5
+ /** Fetches remoteRef into intoRef only; no other ref moves. */
6
+ fetch(remote: string, remoteRef: string, intoRef: string): Promise<FetchResult>;
7
+ /**
8
+ * Compare-and-swap: moves ref to newSha only while it still points at oldSha
9
+ * (oldSha null: only while the ref does not exist). A mismatch throws GIT_FAILED.
10
+ */
11
+ updateRef(ref: string, newSha: string, oldSha: string | null): Promise<void>;
12
+ /**
13
+ * reflogAction: GIT_REFLOG_ACTION for this rebase only, the attempt's own
14
+ * token (rebaseReflogAction of its journal entry), so HEAD's reflog names
15
+ * this attempt in its "(start)" entry (design 13.2, Decision 104).
16
+ */
17
+ rebase(ontoSha: string, reflogAction: string): Promise<RebaseResult>;
18
+ /** Exit code ignored on purpose: the caller verifies the outcome. */
19
+ abortRebase(): Promise<void>;
20
+ /**
21
+ * Ends enmetu's own rebase at the branch's current tip instead of at
22
+ * orig-head: `git rebase --quit` (removes the rebase state, writes no ref,
23
+ * index entry or file), then `git switch --discard-changes <branch>` (index
24
+ * and tree to the branch's tip, whatever they hold, as the abort's reset
25
+ * does). For a branch a commit moved during the rebase, which
26
+ * `git rebase --abort` would reset to orig-head and so drop that commit
27
+ * (design 14.4, Decision 122). Exit codes ignored: the caller verifies.
28
+ */
29
+ endRebaseAtBranch(branchRef: string): Promise<void>;
30
+ /**
31
+ * Pushes exactly this commit to remoteRef (`<sha>:<remoteRef>`), never a
32
+ * ref git resolves at push time, so the caller knows what it pushed
33
+ * (design 13.11, Decision 111).
34
+ */
35
+ push(remote: string, sha: string, remoteRef: string): Promise<PushResult>;
36
+ /** Writes the file's bytes into the object store without filters; returns the blob id. */
37
+ storeBlob(path: string): Promise<string>;
38
+ restoreFromIndex(paths: readonly string[]): Promise<void>;
39
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,14 @@
1
+ export type HaltCode = 'REBASE_ABORT_UNVERIFIED' | 'UPSTREAM_REWRITTEN' | 'QUARANTINE_RESTORE_FAILED' | 'HALT_MARKER_UNREADABLE';
2
+ export interface HaltRecord {
3
+ readonly code: HaltCode;
4
+ readonly message: string;
5
+ readonly cycleId: string | null;
6
+ /** ISO-8601 UTC; 'unknown' for a marker that cannot be parsed (HALT_MARKER_UNREADABLE). */
7
+ readonly at: string;
8
+ }
9
+ export interface IHaltStore {
10
+ /** An unparseable marker means halted, with code HALT_MARKER_UNREADABLE (fail-safe). */
11
+ read(): Promise<HaltRecord | null>;
12
+ set(record: HaltRecord): Promise<void>;
13
+ clear(): Promise<void>;
14
+ }
@@ -0,0 +1 @@
1
+ export {};