@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,71 @@
1
+ import type { CommitLocalChanges } from '../commit/commitLocalChangesController.js';
2
+ import type { IClock } from '../contracts/IClock.js';
3
+ import type { IGitReader } from '../contracts/IGitReader.js';
4
+ import type { IGitWriter } from '../contracts/IGitWriter.js';
5
+ import type { IHaltStore } from '../contracts/IHaltStore.js';
6
+ import type { ILogger } from '../contracts/ILogger.js';
7
+ import type { IPathQuarantine } from '../contracts/IPathQuarantine.js';
8
+ import type { SyncCycleInput, SyncCycleResult } from '../contracts/ISyncCycle.js';
9
+ import type { IntegrateUpstream } from './integrateUpstreamController.js';
10
+ /**
11
+ * Consecutive cycles whose rebase git refused to start before the cycle
12
+ * reports paused REBASE_REFUSED instead of busy (design 12.7, Decision 82).
13
+ */
14
+ export declare const REBASE_REFUSED_AFTER = 3;
15
+ export type { SyncCycleInput, SyncCycleResult };
16
+ export interface SyncCycleDeps {
17
+ readonly git: IGitReader;
18
+ /** HaltGuardGitWriter in production. */
19
+ readonly writer: IGitWriter;
20
+ readonly halt: IHaltStore;
21
+ /** Only for step 4, a memento left pending by an earlier cycle; holding is integrate's. */
22
+ readonly quarantine: IPathQuarantine;
23
+ /** integrateUpstreamController with its deps bound (the same guarded writer, journal and quarantine). */
24
+ readonly integrate: IntegrateUpstream;
25
+ /** commitLocalChangesController with its deps bound; step 5 commits through it (design 12.7). */
26
+ readonly commit: CommitLocalChanges;
27
+ readonly logger: ILogger;
28
+ readonly clock: IClock;
29
+ }
30
+ /**
31
+ * Controller: one sync cycle, the same chain for every trigger (design 1.10).
32
+ * GIT_INDEX_LOCKED thrown by any step returns busy.
33
+ *
34
+ * 1. Halt gate: halt.read(); no git call precedes it (D3).
35
+ * 2. Repository gate: index.lock busy, foreign operation and detached HEAD pause.
36
+ * 3. Upstream of the checked-out branch, every cycle (D6): none pauses NO_UPSTREAM,
37
+ * a local one (remote '.') pauses UPSTREAM_NOT_REMOTE (Decision 28).
38
+ * 4. Pending quarantine memento: restore it; failed halts QUARANTINE_RESTORE_FAILED.
39
+ * 5. Commit local changes, skipped on a clean status (which also clears the
40
+ * memory's partialStagingSince): after the status read the repository
41
+ * state is read again (Decision 52), then deps.commit runs with the commit
42
+ * memory (design 12.7); busy and paused return, committed and nothing go
43
+ * on, waiting goes on and is the outcome unless a later step returns
44
+ * something else. The check right before the commit is the commit step's,
45
+ * under index.lock.
46
+ * 6. Remote check: ls-remote, tracking ref, verified ref (seeded from tracking when missing).
47
+ * 7. Verify and fetch: fetch into the incoming ref when the remote moved; the remote sha
48
+ * must descend from the verified sha, else halt UPSTREAM_REWRITTEN; then move the
49
+ * tracking and verified refs with compare-and-swap.
50
+ * 8. Divergence against the remote sha.
51
+ * 9. Integrate when behind (deps.integrate); conflict, busy, paused and halted are returned unchanged.
52
+ * When step 5 returned waiting PARTIAL_SELECTION_STAGED, or waiting and
53
+ * git.hasStagedChanges() says the real index differs from HEAD (git would
54
+ * refuse the rebase), integrate is skipped, UNPUSHED reported and waiting
55
+ * returned (Decision 84).
56
+ * not-started: the count of consecutive refusals (input.notStartedRebases)
57
+ * goes up by one; the first logs REBASE_NOT_STARTED with git's first stderr
58
+ * line; UNPUSHED is reported; the outcome is busy, or paused REBASE_REFUSED
59
+ * from REBASE_REFUSED_AFTER on (Decision 82). Any other integrate result, or
60
+ * a branch not behind, resets the count to 0; a cycle that returns before
61
+ * step 8 carries it unchanged.
62
+ * 10. Push when ahead: the branch's commit, resolved once, pushed by its sha;
63
+ * the verified ref moves to that sha (Decision 111). A branch gone since
64
+ * step 2 pauses BRANCH_CHANGED.
65
+ * 11. synced.
66
+ * UNPUSHED is logged with the count on every cycle that reaches the remote check and ends
67
+ * with local commits not on the upstream, halts aside (D2); the pauses and the busy index
68
+ * of steps 2, 3 and 5 return before it and are reported once (Decision 53).
69
+ * PAUSED, BUSY and OFFLINE are the runner's to log, once per change.
70
+ */
71
+ export declare function syncCycleController(input: SyncCycleInput, deps: SyncCycleDeps): Promise<SyncCycleResult>;
@@ -0,0 +1,345 @@
1
+ import { EnmetuError } from '../contracts/errors.js';
2
+ import { EMPTY_COMMIT_MEMORY, } from '../contracts/syncTypes.js';
3
+ import { withLastMove } from '../core/commit/commitMemory.js';
4
+ import { withCycleId } from '../core/errors/withCycleId.js';
5
+ import { incomingRefFor, verifiedRefFor } from '../core/git/enmetuRefs.js';
6
+ import { logRestoreWarnings } from '../core/quarantine/restoreWarnings.js';
7
+ import { recordHalt } from '../halt/recordHalt.js';
8
+ /** The remote name git gives an upstream that is a branch of this repository. */
9
+ const LOCAL_REMOTE = '.';
10
+ const SHORT_SHA_LENGTH = 7;
11
+ /**
12
+ * Consecutive cycles whose rebase git refused to start before the cycle
13
+ * reports paused REBASE_REFUSED instead of busy (design 12.7, Decision 82).
14
+ */
15
+ export const REBASE_REFUSED_AFTER = 3;
16
+ /**
17
+ * Controller: one sync cycle, the same chain for every trigger (design 1.10).
18
+ * GIT_INDEX_LOCKED thrown by any step returns busy.
19
+ *
20
+ * 1. Halt gate: halt.read(); no git call precedes it (D3).
21
+ * 2. Repository gate: index.lock busy, foreign operation and detached HEAD pause.
22
+ * 3. Upstream of the checked-out branch, every cycle (D6): none pauses NO_UPSTREAM,
23
+ * a local one (remote '.') pauses UPSTREAM_NOT_REMOTE (Decision 28).
24
+ * 4. Pending quarantine memento: restore it; failed halts QUARANTINE_RESTORE_FAILED.
25
+ * 5. Commit local changes, skipped on a clean status (which also clears the
26
+ * memory's partialStagingSince): after the status read the repository
27
+ * state is read again (Decision 52), then deps.commit runs with the commit
28
+ * memory (design 12.7); busy and paused return, committed and nothing go
29
+ * on, waiting goes on and is the outcome unless a later step returns
30
+ * something else. The check right before the commit is the commit step's,
31
+ * under index.lock.
32
+ * 6. Remote check: ls-remote, tracking ref, verified ref (seeded from tracking when missing).
33
+ * 7. Verify and fetch: fetch into the incoming ref when the remote moved; the remote sha
34
+ * must descend from the verified sha, else halt UPSTREAM_REWRITTEN; then move the
35
+ * tracking and verified refs with compare-and-swap.
36
+ * 8. Divergence against the remote sha.
37
+ * 9. Integrate when behind (deps.integrate); conflict, busy, paused and halted are returned unchanged.
38
+ * When step 5 returned waiting PARTIAL_SELECTION_STAGED, or waiting and
39
+ * git.hasStagedChanges() says the real index differs from HEAD (git would
40
+ * refuse the rebase), integrate is skipped, UNPUSHED reported and waiting
41
+ * returned (Decision 84).
42
+ * not-started: the count of consecutive refusals (input.notStartedRebases)
43
+ * goes up by one; the first logs REBASE_NOT_STARTED with git's first stderr
44
+ * line; UNPUSHED is reported; the outcome is busy, or paused REBASE_REFUSED
45
+ * from REBASE_REFUSED_AFTER on (Decision 82). Any other integrate result, or
46
+ * a branch not behind, resets the count to 0; a cycle that returns before
47
+ * step 8 carries it unchanged.
48
+ * 10. Push when ahead: the branch's commit, resolved once, pushed by its sha;
49
+ * the verified ref moves to that sha (Decision 111). A branch gone since
50
+ * step 2 pauses BRANCH_CHANGED.
51
+ * 11. synced.
52
+ * UNPUSHED is logged with the count on every cycle that reaches the remote check and ends
53
+ * with local commits not on the upstream, halts aside (D2); the pauses and the busy index
54
+ * of steps 2, 3 and 5 return before it and are reported once (Decision 53).
55
+ * PAUSED, BUSY and OFFLINE are the runner's to log, once per change.
56
+ */
57
+ export async function syncCycleController(input, deps) {
58
+ const carried = {
59
+ commitMemory: input.commitMemory,
60
+ notStartedRebases: input.notStartedRebases,
61
+ };
62
+ const outcome = (cycleOutcome) => resultOf(cycleOutcome, null, carried);
63
+ try {
64
+ return await runCycle(input, deps, carried, outcome);
65
+ }
66
+ catch (error) {
67
+ if (error instanceof EnmetuError && error.code === 'GIT_INDEX_LOCKED') {
68
+ return outcome({ kind: 'busy' });
69
+ }
70
+ throw withCycleId(error, input.cycleId);
71
+ }
72
+ }
73
+ async function runCycle(input, deps, carried, outcome) {
74
+ const { cycleId } = input;
75
+ // --- Step 1: halt gate, before any git call (D3) ---
76
+ const haltRecord = await deps.halt.read();
77
+ if (haltRecord !== null) {
78
+ return outcome({ kind: 'halted', code: haltRecord.code });
79
+ }
80
+ // --- Step 2: repository gate ---
81
+ const state = await deps.git.repositoryState();
82
+ if (state.indexLocked) {
83
+ return outcome({ kind: 'busy' });
84
+ }
85
+ if (state.operation !== null) {
86
+ // A person's rebase, merge, cherry-pick, revert or bisect is never touched.
87
+ return outcome({ kind: 'paused', reason: 'FOREIGN_OPERATION' });
88
+ }
89
+ if (state.branchRef === null) {
90
+ return outcome({ kind: 'paused', reason: 'DETACHED_HEAD' });
91
+ }
92
+ const branchRef = state.branchRef;
93
+ // --- Step 3: the upstream of whatever branch is checked out now (D6) ---
94
+ const upstream = await deps.git.upstream(branchRef);
95
+ if (upstream === null) {
96
+ return outcome({ kind: 'paused', reason: 'NO_UPSTREAM' });
97
+ }
98
+ if (upstream.remote === LOCAL_REMOTE) {
99
+ // A branch switched to mid-run whose upstream is another local branch: a
100
+ // push would fast-forward that branch, which nobody asked enmetu to touch.
101
+ return outcome({ kind: 'paused', reason: 'UPSTREAM_NOT_REMOTE' });
102
+ }
103
+ // --- Step 4: a memento an earlier cycle left pending is restored first ---
104
+ if (await deps.quarantine.hasPending()) {
105
+ const restore = await deps.quarantine.restore();
106
+ if (restore.kind === 'failed') {
107
+ return outcome(await haltWith('QUARANTINE_RESTORE_FAILED', `restoring a pending quarantine memento failed (${restore.detail}); the memento is kept`, cycleId, deps));
108
+ }
109
+ if (restore.kind === 'restored') {
110
+ logRestoreWarnings(restore, cycleId, deps.logger);
111
+ }
112
+ }
113
+ // --- Step 5: commit local changes; a clean status writes nothing to the index ---
114
+ let waiting = null;
115
+ // The commit's parent when this cycle committed: step 9 records the pull
116
+ // from there, so the repair covers the commit and the pull in one move.
117
+ let committedFrom = null;
118
+ if ((await deps.git.status()).length > 0) {
119
+ // A person's merge, cherry-pick or revert that stopped after step 2 is in
120
+ // that status; the commit step would only find it under index.lock (Decision 52).
121
+ if ((await deps.git.repositoryState()).operation !== null) {
122
+ return outcome({ kind: 'paused', reason: 'FOREIGN_OPERATION' });
123
+ }
124
+ const committed = await deps.commit({
125
+ cycleId,
126
+ memory: carried.commitMemory ?? EMPTY_COMMIT_MEMORY,
127
+ branchRef,
128
+ });
129
+ carried.commitMemory = committed.memory;
130
+ if (committed.kind === 'busy') {
131
+ return outcome({ kind: 'busy' });
132
+ }
133
+ if (committed.kind === 'paused') {
134
+ return outcome({ kind: 'paused', reason: committed.reason });
135
+ }
136
+ if (committed.kind === 'waiting') {
137
+ waiting = committed.reason;
138
+ }
139
+ if (committed.kind === 'committed') {
140
+ committedFrom = committed.memory.lastMove?.from ?? null;
141
+ }
142
+ }
143
+ else if (carried.commitMemory !== undefined) {
144
+ // A clean tree has no staged selection, so a wait for one is over.
145
+ carried.commitMemory = {
146
+ partialStagingSince: null,
147
+ rejection: carried.commitMemory.rejection,
148
+ residueTree: carried.commitMemory.residueTree,
149
+ lastMove: carried.commitMemory.lastMove,
150
+ };
151
+ }
152
+ // --- Step 6: remote check ---
153
+ const verifiedRef = verifiedRefFor(branchRef);
154
+ const remoteHead = await deps.git.remoteHead(upstream.remote, upstream.remoteRef);
155
+ const trackingSha = await deps.git.resolveCommit(upstream.trackingRef);
156
+ let verifiedSha = await deps.git.resolveCommit(verifiedRef);
157
+ if (remoteHead.kind === 'unreachable') {
158
+ await reportUnpushedAgainstLastKnown(trackingSha ?? verifiedSha, upstream, cycleId, deps);
159
+ return outcome({ kind: 'offline' });
160
+ }
161
+ if (remoteHead.kind === 'absent') {
162
+ // A deleted remote branch is never recreated by enmetu, so commits pile
163
+ // up locally while it is gone; they are reported every cycle (D2).
164
+ await reportUnpushedAgainstLastKnown(trackingSha ?? verifiedSha, upstream, cycleId, deps);
165
+ return outcome({ kind: 'paused', reason: 'UPSTREAM_GONE' });
166
+ }
167
+ const remoteSha = remoteHead.sha;
168
+ if (verifiedSha === null && trackingSha !== null) {
169
+ // First start: the tracking ref is the best reference point there is.
170
+ await deps.writer.updateRef(verifiedRef, trackingSha, null);
171
+ verifiedSha = trackingSha;
172
+ }
173
+ // --- Step 7: verify and fetch; refs move only after the ancestry check ---
174
+ if (remoteSha !== trackingSha) {
175
+ const fetched = await deps.writer.fetch(upstream.remote, upstream.remoteRef, incomingRefFor(branchRef));
176
+ if (fetched.kind === 'failed') {
177
+ await reportUnpushedAgainstLastKnown(trackingSha ?? verifiedSha, upstream, cycleId, deps);
178
+ return outcome({ kind: 'offline' });
179
+ }
180
+ }
181
+ // Checked even when the fetch was skipped: a person's own fetch may already
182
+ // have moved the tracking ref to a rewritten upstream.
183
+ if (verifiedSha !== null && verifiedSha !== remoteSha) {
184
+ if (!(await deps.git.isAncestor(verifiedSha, remoteSha))) {
185
+ return outcome(await haltWith('UPSTREAM_REWRITTEN', `${upstream.remote} ${upstream.remoteRef} now points at ${remoteSha}, which does not descend from ${verifiedSha}, the upstream enmetu last verified; the upstream was rewritten`, cycleId, deps));
186
+ }
187
+ }
188
+ if (trackingSha !== remoteSha) {
189
+ await deps.writer.updateRef(upstream.trackingRef, remoteSha, trackingSha);
190
+ }
191
+ if (verifiedSha !== remoteSha) {
192
+ await deps.writer.updateRef(verifiedRef, remoteSha, verifiedSha);
193
+ }
194
+ // --- Step 8: divergence against the verified remote sha ---
195
+ const divergence = await deps.git.divergence(remoteSha);
196
+ const behind = divergence.behind;
197
+ let ahead = divergence.ahead;
198
+ // --- Step 9: integrate when behind ---
199
+ if (behind === 0) {
200
+ carried.notStartedRebases = resetCount(carried.notStartedRebases);
201
+ }
202
+ else {
203
+ // git rebase refuses an index that differs from HEAD (Decision 84). A
204
+ // selection step 5 waited for holds integrate back even when the person
205
+ // committed it since: step 5 committed nothing, so the person's other
206
+ // edits are still uncommitted, and the quarantine would hold them while a
207
+ // rebase runs under the person's hands; the next cycle commits first.
208
+ if (waiting === 'PARTIAL_SELECTION_STAGED' ||
209
+ (waiting !== null && (await deps.git.hasStagedChanges()))) {
210
+ reportUnpushed(ahead, upstream, cycleId, deps);
211
+ return outcome({ kind: 'waiting', reason: waiting });
212
+ }
213
+ const pulledFrom = committedFrom ?? (await deps.git.headCommit());
214
+ const integrated = await deps.integrate({
215
+ cycleId,
216
+ branchRef,
217
+ ontoSha: remoteSha,
218
+ behind,
219
+ knownConflict: input.knownConflict,
220
+ });
221
+ if (integrated.kind === 'not-started') {
222
+ const refusals = (carried.notStartedRebases ?? 0) + 1;
223
+ carried.notStartedRebases = refusals;
224
+ if (refusals === 1) {
225
+ deps.logger.log({
226
+ level: 'WARN',
227
+ code: 'REBASE_NOT_STARTED',
228
+ message: `git did not start the rebase onto ${shortSha(remoteSha)} (${firstLine(integrated.detail)}); enmetu retries on the next cycle`,
229
+ cycleId,
230
+ });
231
+ }
232
+ reportUnpushed(ahead, upstream, cycleId, deps);
233
+ return outcome(refusals >= REBASE_REFUSED_AFTER
234
+ ? { kind: 'paused', reason: 'REBASE_REFUSED' }
235
+ : { kind: 'busy' });
236
+ }
237
+ carried.notStartedRebases = resetCount(carried.notStartedRebases);
238
+ if (integrated.kind === 'conflict') {
239
+ reportUnpushed(ahead, upstream, cycleId, deps);
240
+ return resultOf({ kind: 'conflict' }, integrated.conflict, carried);
241
+ }
242
+ if (integrated.kind === 'halted') {
243
+ // recordHalt already logged HALTED with the reason.
244
+ return outcome(integrated);
245
+ }
246
+ if (integrated.kind !== 'rebased') {
247
+ // Behind and not rebased, so nothing is pushed this cycle (D2).
248
+ reportUnpushed(ahead, upstream, cycleId, deps);
249
+ return outcome(integrated);
250
+ }
251
+ // A git command of the person's that read the index before the rebase
252
+ // and writes it afterwards puts the pre-pull entries back (Decision 98).
253
+ const pulledTo = await deps.git.headCommit();
254
+ if (pulledFrom !== null && pulledTo !== null && pulledTo !== pulledFrom) {
255
+ carried.commitMemory = withLastMove(carried.commitMemory ?? EMPTY_COMMIT_MEMORY, { from: pulledFrom, to: pulledTo });
256
+ }
257
+ ahead = (await deps.git.divergence(remoteSha)).ahead;
258
+ }
259
+ // --- Step 10: push when ahead ---
260
+ if (ahead > 0) {
261
+ // The branch's commit, read once and pushed by its sha: HEAD may be
262
+ // detached at a person's half-done replay by now, and the verified ref
263
+ // must name what the remote received (Decision 111).
264
+ const pushedSha = await deps.git.resolveCommit(branchRef);
265
+ if (pushedSha === null) {
266
+ // The branch step 2 resolved is gone; the next cycle reads it again.
267
+ reportUnpushed(ahead, upstream, cycleId, deps);
268
+ return outcome({ kind: 'paused', reason: 'BRANCH_CHANGED' });
269
+ }
270
+ const pushed = await deps.writer.push(upstream.remote, pushedSha, upstream.remoteRef);
271
+ if (pushed.kind !== 'pushed') {
272
+ deps.logger.log({
273
+ level: 'WARN',
274
+ code: pushed.kind === 'rejected' ? 'PUSH_REJECTED' : 'PUSH_FAILED',
275
+ message: pushed.detail,
276
+ cycleId,
277
+ });
278
+ reportUnpushed(ahead, upstream, cycleId, deps);
279
+ return outcome({ kind: 'push-failed' });
280
+ }
281
+ deps.logger.log({
282
+ level: 'INFO',
283
+ code: 'PUSHED',
284
+ message: ahead === 1 ? '1 commit' : `${ahead} commits`,
285
+ cycleId,
286
+ });
287
+ await deps.writer.updateRef(verifiedRef, pushedSha, remoteSha);
288
+ }
289
+ // --- Step 11: synced, or still waiting for what step 5 could not commit ---
290
+ return outcome(waiting === null ? { kind: 'synced' } : { kind: 'waiting', reason: waiting });
291
+ }
292
+ /** The result with what the cycle carries; every outcome but a conflict has no conflict key. */
293
+ function resultOf(cycleOutcome, conflict, carried) {
294
+ return {
295
+ outcome: cycleOutcome,
296
+ conflict,
297
+ commitMemory: carried.commitMemory,
298
+ notStartedRebases: carried.notStartedRebases,
299
+ };
300
+ }
301
+ /** A count the input carried goes back to 0; an absent one stays absent, which means 0. */
302
+ function resetCount(count) {
303
+ return count === undefined ? undefined : 0;
304
+ }
305
+ function shortSha(sha) {
306
+ return sha.slice(0, SHORT_SHA_LENGTH);
307
+ }
308
+ function firstLine(text) {
309
+ return (text.trim().split('\n')[0] ?? '').trim();
310
+ }
311
+ async function haltWith(code, message, cycleId, deps) {
312
+ await recordHalt({ code, message, cycleId }, deps);
313
+ return { kind: 'halted', code };
314
+ }
315
+ function reportUnpushed(ahead, upstream, cycleId, deps) {
316
+ if (ahead === 0) {
317
+ return;
318
+ }
319
+ deps.logger.log({
320
+ level: 'WARN',
321
+ code: 'UNPUSHED',
322
+ message: `${ahead === 1 ? '1 local commit' : `${ahead} local commits`} not on ${upstream.remote} ${upstream.remoteRef}`,
323
+ cycleId,
324
+ });
325
+ }
326
+ /**
327
+ * Remote not readable or gone: counted against the last known upstream, the
328
+ * tracking ref or else enmetu's verified ref, uncounted only when neither exists.
329
+ * The tracking ref comes first because a person's fetch keeps it the newer
330
+ * observation of the remote; the verified ref covers a tracking ref that
331
+ * `git fetch --prune` removed after the remote branch was deleted.
332
+ */
333
+ async function reportUnpushedAgainstLastKnown(lastKnownSha, upstream, cycleId, deps) {
334
+ if (lastKnownSha === null) {
335
+ deps.logger.log({
336
+ level: 'WARN',
337
+ code: 'UNPUSHED',
338
+ message: `local commits may not be on ${upstream.remote} ${upstream.remoteRef} (count unknown: no upstream was ever fetched or verified)`,
339
+ cycleId,
340
+ });
341
+ return;
342
+ }
343
+ const { ahead } = await deps.git.divergence(lastKnownSha);
344
+ reportUnpushed(ahead, upstream, cycleId, deps);
345
+ }
@@ -0,0 +1,24 @@
1
+ import type { ILogger } from '../contracts/ILogger.js';
2
+ import type { ISyncCycle } from '../contracts/ISyncCycle.js';
3
+ import type { ISyncCycleRunner } from '../contracts/ISyncCycleRunner.js';
4
+ import { type CycleOutcomeKind } from '../contracts/syncTypes.js';
5
+ /**
6
+ * Service: the memory that spans cycles. Numbers cycles c1, c2, ...; passes
7
+ * the previous ConflictKey in and replaces it with the result's, and does the
8
+ * same for the commit memory and the count of refused rebases (design 12.7);
9
+ * logs WAITING, PAUSED, BUSY and OFFLINE when they begin or change and RESUMED
10
+ * when they end; turns a thrown error into CYCLE_FAILED and outcome 'failed'.
11
+ */
12
+ export declare class SyncCycleRunner implements ISyncCycleRunner {
13
+ private readonly cycle;
14
+ private readonly logger;
15
+ private cycleCount;
16
+ private knownConflict;
17
+ private quiet;
18
+ /** null until a cycle carried a commit memory or a refusal count; absent means empty and 0 (design 12.7). */
19
+ private carried;
20
+ constructor(cycle: ISyncCycle, logger: ILogger);
21
+ run(): Promise<CycleOutcomeKind>;
22
+ private inputFor;
23
+ private reportQuietTransition;
24
+ }
@@ -0,0 +1,169 @@
1
+ import { EMPTY_COMMIT_MEMORY, } from '../contracts/syncTypes.js';
2
+ import { errorMessage } from '../core/errors/errorMessage.js';
3
+ const WAITING_MESSAGES = {
4
+ PARTIAL_SELECTION_STAGED: 'waiting: a partial selection is staged; nothing is committed or pulled until you commit or unstage it, and after partialStagingWaitMs enmetu commits everything',
5
+ COMMIT_HOOK_REJECTED: 'waiting: a commit hook rejected the commit; enmetu tries again when the files change',
6
+ };
7
+ /**
8
+ * Service: the memory that spans cycles. Numbers cycles c1, c2, ...; passes
9
+ * the previous ConflictKey in and replaces it with the result's, and does the
10
+ * same for the commit memory and the count of refused rebases (design 12.7);
11
+ * logs WAITING, PAUSED, BUSY and OFFLINE when they begin or change and RESUMED
12
+ * when they end; turns a thrown error into CYCLE_FAILED and outcome 'failed'.
13
+ */
14
+ export class SyncCycleRunner {
15
+ cycle;
16
+ logger;
17
+ cycleCount = 0;
18
+ knownConflict = null;
19
+ quiet = null;
20
+ /** null until a cycle carried a commit memory or a refusal count; absent means empty and 0 (design 12.7). */
21
+ carried = null;
22
+ constructor(cycle, logger) {
23
+ this.cycle = cycle;
24
+ this.logger = logger;
25
+ }
26
+ async run() {
27
+ this.cycleCount += 1;
28
+ const cycleId = `c${this.cycleCount}`;
29
+ try {
30
+ const result = await this.cycle.run(this.inputFor(cycleId));
31
+ // A busy cycle carries no key, so the next cycle re-runs the rebase
32
+ // that the lock interrupted (Decision 11).
33
+ this.knownConflict = result.conflict;
34
+ if (this.carried !== null ||
35
+ result.commitMemory !== undefined ||
36
+ result.notStartedRebases !== undefined) {
37
+ this.carried = {
38
+ commitMemory: result.commitMemory ?? EMPTY_COMMIT_MEMORY,
39
+ notStartedRebases: result.notStartedRebases ?? 0,
40
+ };
41
+ }
42
+ this.reportQuietTransition(result.outcome, cycleId);
43
+ return result.outcome.kind;
44
+ }
45
+ catch (error) {
46
+ // No result, so neither the conflict key, the commit memory, the
47
+ // refusal count nor the quiet state is replaced: a throw says nothing
48
+ // about whether any of them has ended.
49
+ this.logger.log({
50
+ level: 'ERROR',
51
+ code: 'CYCLE_FAILED',
52
+ message: describeError(error),
53
+ cycleId,
54
+ });
55
+ return 'failed';
56
+ }
57
+ }
58
+ inputFor(cycleId) {
59
+ if (this.carried === null) {
60
+ return { cycleId, knownConflict: this.knownConflict };
61
+ }
62
+ return {
63
+ cycleId,
64
+ knownConflict: this.knownConflict,
65
+ commitMemory: this.carried.commitMemory,
66
+ notStartedRebases: this.carried.notStartedRebases,
67
+ };
68
+ }
69
+ reportQuietTransition(outcome, cycleId) {
70
+ const previous = this.quiet;
71
+ const current = asQuiet(outcome);
72
+ if (current !== null) {
73
+ this.quiet = current;
74
+ if (previous === null || !sameQuiet(previous, current)) {
75
+ this.logger.log(quietEntry(current, cycleId));
76
+ }
77
+ return;
78
+ }
79
+ // A halt is reported by the controller and ends the run; calling it a
80
+ // resumption would contradict the HALTED line that precedes it.
81
+ if (outcome.kind === 'halted') {
82
+ return;
83
+ }
84
+ this.quiet = null;
85
+ if (previous !== null) {
86
+ this.logger.log({
87
+ level: 'INFO',
88
+ code: 'RESUMED',
89
+ message: `sync resumed after ${describeQuiet(previous)}`,
90
+ cycleId,
91
+ });
92
+ }
93
+ }
94
+ }
95
+ function asQuiet(outcome) {
96
+ switch (outcome.kind) {
97
+ case 'paused':
98
+ case 'busy':
99
+ case 'offline':
100
+ case 'waiting':
101
+ return outcome;
102
+ default:
103
+ return null;
104
+ }
105
+ }
106
+ function sameQuiet(left, right) {
107
+ if (left.kind === 'paused' && right.kind === 'paused') {
108
+ return left.reason === right.reason;
109
+ }
110
+ if (left.kind === 'waiting' && right.kind === 'waiting') {
111
+ return left.reason === right.reason;
112
+ }
113
+ return left.kind === right.kind;
114
+ }
115
+ function quietEntry(outcome, cycleId) {
116
+ switch (outcome.kind) {
117
+ case 'paused':
118
+ return {
119
+ level: 'WARN',
120
+ code: 'PAUSED',
121
+ message: `sync paused: ${outcome.reason}`,
122
+ cycleId,
123
+ };
124
+ case 'busy':
125
+ return {
126
+ level: 'WARN',
127
+ code: 'BUSY',
128
+ message: 'git is busy in this repository; retrying on the next cycle',
129
+ cycleId,
130
+ };
131
+ case 'offline':
132
+ return {
133
+ level: 'WARN',
134
+ code: 'OFFLINE',
135
+ message: 'remote unreachable; retrying on every poll',
136
+ cycleId,
137
+ };
138
+ case 'waiting':
139
+ return {
140
+ level: 'WARN',
141
+ code: 'WAITING',
142
+ message: WAITING_MESSAGES[outcome.reason],
143
+ cycleId,
144
+ };
145
+ }
146
+ }
147
+ function describeQuiet(outcome) {
148
+ switch (outcome.kind) {
149
+ case 'paused':
150
+ return `pause ${outcome.reason}`;
151
+ case 'busy':
152
+ return 'busy git';
153
+ case 'offline':
154
+ return 'offline';
155
+ case 'waiting':
156
+ return `waiting ${outcome.reason}`;
157
+ }
158
+ }
159
+ /** The message, prefixed with the structured code when the error carries one (EnmetuError, Node errno errors). */
160
+ function describeError(error) {
161
+ const message = errorMessage(error);
162
+ if (typeof error === 'object' &&
163
+ error !== null &&
164
+ 'code' in error &&
165
+ typeof error.code === 'string') {
166
+ return `${error.code}: ${message}`;
167
+ }
168
+ return message;
169
+ }
@@ -0,0 +1,32 @@
1
+ import type { IClock } from '../contracts/IClock.js';
2
+ import type { ISyncCycleRunner } from '../contracts/ISyncCycleRunner.js';
3
+ import type { ISyncScheduler } from '../contracts/ISyncScheduler.js';
4
+ import { type SchedulerTiming } from '../core/schedule/schedulerStep.js';
5
+ type EndReason = 'stopped' | 'halted';
6
+ /**
7
+ * Service, the imperative shell around schedulerStep: it holds the state,
8
+ * feeds events in, and executes the effects with IClock and the runner. It
9
+ * contains no timing rule of its own.
10
+ */
11
+ export declare class SyncScheduler implements ISyncScheduler {
12
+ private readonly runner;
13
+ private readonly clock;
14
+ private readonly timing;
15
+ private state;
16
+ private quietTimer;
17
+ private pollTimer;
18
+ private running;
19
+ private endReason;
20
+ private resolveEnd;
21
+ constructor(runner: ISyncCycleRunner, clock: IClock, timing: SchedulerTiming);
22
+ run(): Promise<EndReason>;
23
+ notifyChange(): void;
24
+ stop(): void;
25
+ private dispatch;
26
+ private execute;
27
+ private startCycle;
28
+ private finishCycle;
29
+ /** A deadline already passed (a late timer callback) fires at once. */
30
+ private delayUntil;
31
+ }
32
+ export {};