@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,69 @@
1
+ import { errorMessage } from '../core/errors/errorMessage.js';
2
+ import { stateRecordStep } from '../core/state/stateRecordStep.js';
3
+ /**
4
+ * Service, the imperative shell around stateRecordStep (design 12.1): holds
5
+ * the current record (read from the store once, at the first event, so the
6
+ * history survives a restart), applies each event with the clock's time and
7
+ * the history cap, and writes the result. A failed read means no record;
8
+ * a failed write logs STATE_WRITE_FAILED once until a write succeeds again
9
+ * and is never thrown, so state recording cannot stop syncing.
10
+ */
11
+ export class StateRecorder {
12
+ store;
13
+ clock;
14
+ logger;
15
+ historyCap;
16
+ current = null;
17
+ loaded = false;
18
+ writeFailing = false;
19
+ constructor(store, clock, logger, historyCap) {
20
+ this.store = store;
21
+ this.clock = clock;
22
+ this.logger = logger;
23
+ this.historyCap = historyCap;
24
+ }
25
+ async record(event) {
26
+ const previous = await this.previous();
27
+ const at = new Date(this.clock.now()).toISOString();
28
+ // Kept in memory even when the write fails, so the next event builds on
29
+ // what the process knows and the next successful write carries it.
30
+ this.current = stateRecordStep(previous, event, at, this.historyCap);
31
+ try {
32
+ await this.store.write(this.current);
33
+ this.writeFailing = false;
34
+ }
35
+ catch (error) {
36
+ if (!this.writeFailing) {
37
+ this.writeFailing = true;
38
+ this.logger.log({
39
+ level: 'WARN',
40
+ code: 'STATE_WRITE_FAILED',
41
+ message: `state.json could not be written: ${errorMessage(error)}`,
42
+ });
43
+ }
44
+ }
45
+ }
46
+ /** The rebase abort's view of this recorder (design 14.15); never throws, like record. */
47
+ async orphaned(facts) {
48
+ await this.record({
49
+ type: 'commitOrphaned',
50
+ commit: facts.commit,
51
+ base: facts.base,
52
+ subject: facts.subject,
53
+ });
54
+ }
55
+ async previous() {
56
+ if (this.loaded) {
57
+ return this.current;
58
+ }
59
+ this.loaded = true;
60
+ try {
61
+ this.current = await this.store.read();
62
+ }
63
+ catch {
64
+ // An unreadable record only costs the history of earlier runs.
65
+ this.current = null;
66
+ }
67
+ return this.current;
68
+ }
69
+ }
@@ -0,0 +1,24 @@
1
+ import type { IGitReader } from '../contracts/IGitReader.js';
2
+ import type { IStateRecorder } from '../contracts/IStateRecorder.js';
3
+ import type { ISyncCycle, SyncCycleInput, SyncCycleResult } from '../contracts/ISyncCycle.js';
4
+ /**
5
+ * Decorator over ISyncCycle (design 12.1, Decision 65): runs the wrapped
6
+ * cycle, then reads the upstream facts from git (`pushed` is the verified
7
+ * ref; `unpushed` is the ahead count against the tracking ref, else the
8
+ * verified ref) and records cycleFinished; a cycle that throws is recorded
9
+ * cycleFailed and the error is rethrown unchanged. The result is returned
10
+ * unchanged; a failed facts read records null facts. The controller, the
11
+ * runner and the scheduler do not know it exists.
12
+ */
13
+ export declare class StateRecordingSyncCycle implements ISyncCycle {
14
+ private readonly inner;
15
+ private readonly recorder;
16
+ private readonly git;
17
+ constructor(inner: ISyncCycle, recorder: IStateRecorder, git: IGitReader);
18
+ run(input: SyncCycleInput): Promise<SyncCycleResult>;
19
+ /**
20
+ * Read after the cycle, so the counts include what it committed and pushed.
21
+ * Counted against the base the cycle's UNPUSHED line uses (Decision 59).
22
+ */
23
+ private upstreamFacts;
24
+ }
@@ -0,0 +1,71 @@
1
+ import { verifiedRefFor } from '../core/git/enmetuRefs.js';
2
+ const UNKNOWN_FACTS = { pushed: null, unpushed: null };
3
+ /**
4
+ * Decorator over ISyncCycle (design 12.1, Decision 65): runs the wrapped
5
+ * cycle, then reads the upstream facts from git (`pushed` is the verified
6
+ * ref; `unpushed` is the ahead count against the tracking ref, else the
7
+ * verified ref) and records cycleFinished; a cycle that throws is recorded
8
+ * cycleFailed and the error is rethrown unchanged. The result is returned
9
+ * unchanged; a failed facts read records null facts. The controller, the
10
+ * runner and the scheduler do not know it exists.
11
+ */
12
+ export class StateRecordingSyncCycle {
13
+ inner;
14
+ recorder;
15
+ git;
16
+ constructor(inner, recorder, git) {
17
+ this.inner = inner;
18
+ this.recorder = recorder;
19
+ this.git = git;
20
+ }
21
+ async run(input) {
22
+ let result;
23
+ try {
24
+ result = await this.inner.run(input);
25
+ }
26
+ catch (error) {
27
+ await this.recorder.record({
28
+ type: 'cycleFailed',
29
+ cycleId: input.cycleId,
30
+ });
31
+ throw error;
32
+ }
33
+ await this.recorder.record({
34
+ type: 'cycleFinished',
35
+ cycleId: input.cycleId,
36
+ outcome: result.outcome,
37
+ conflict: result.conflict,
38
+ upstream: await this.upstreamFacts(),
39
+ });
40
+ return result;
41
+ }
42
+ /**
43
+ * Read after the cycle, so the counts include what it committed and pushed.
44
+ * Counted against the base the cycle's UNPUSHED line uses (Decision 59).
45
+ */
46
+ async upstreamFacts() {
47
+ try {
48
+ const { branchRef } = await this.git.repositoryState();
49
+ if (branchRef === null) {
50
+ return UNKNOWN_FACTS;
51
+ }
52
+ const upstream = await this.git.upstream(branchRef);
53
+ if (upstream === null) {
54
+ return UNKNOWN_FACTS;
55
+ }
56
+ const trackingSha = await this.git.resolveCommit(upstream.trackingRef);
57
+ const verifiedSha = await this.git.resolveCommit(verifiedRefFor(branchRef));
58
+ const base = trackingSha ?? verifiedSha;
59
+ if (base === null) {
60
+ return { pushed: verifiedSha, unpushed: null };
61
+ }
62
+ const { ahead } = await this.git.divergence(base);
63
+ return { pushed: verifiedSha, unpushed: ahead };
64
+ }
65
+ catch {
66
+ // The record is a report; a failed read must not fail a cycle that
67
+ // already ran, and null says "unknown" truthfully.
68
+ return UNKNOWN_FACTS;
69
+ }
70
+ }
71
+ }
@@ -0,0 +1,16 @@
1
+ import type { ISyncScheduler } from '../contracts/ISyncScheduler.js';
2
+ /**
3
+ * Decorator over ISyncScheduler (Decision 92): a stop request also aborts the
4
+ * signal the hook-running facades carry, so a repository hook that would run
5
+ * for up to hookTimeoutMs is ended at once, which is a rejection; the
6
+ * running cycle then finishes in the time its remaining git commands take.
7
+ * The scheduler and the daemon stay unaware of hooks.
8
+ */
9
+ export declare class HookAbortingSyncScheduler implements ISyncScheduler {
10
+ private readonly inner;
11
+ private readonly hooks;
12
+ constructor(inner: ISyncScheduler, hooks: AbortController);
13
+ run(): Promise<'stopped' | 'halted'>;
14
+ notifyChange(): void;
15
+ stop(): void;
16
+ }
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Decorator over ISyncScheduler (Decision 92): a stop request also aborts the
3
+ * signal the hook-running facades carry, so a repository hook that would run
4
+ * for up to hookTimeoutMs is ended at once, which is a rejection; the
5
+ * running cycle then finishes in the time its remaining git commands take.
6
+ * The scheduler and the daemon stay unaware of hooks.
7
+ */
8
+ export class HookAbortingSyncScheduler {
9
+ inner;
10
+ hooks;
11
+ constructor(inner, hooks) {
12
+ this.inner = inner;
13
+ this.hooks = hooks;
14
+ }
15
+ run() {
16
+ return this.inner.run();
17
+ }
18
+ notifyChange() {
19
+ this.inner.notifyChange();
20
+ }
21
+ stop() {
22
+ // Abort first: the inner stop waits for the running cycle, which may be
23
+ // inside a hook right now.
24
+ this.hooks.abort();
25
+ this.inner.stop();
26
+ }
27
+ }
@@ -0,0 +1,95 @@
1
+ import type { IClock } from '../contracts/IClock.js';
2
+ import type { IGitReader } from '../contracts/IGitReader.js';
3
+ import type { IGitWriter } from '../contracts/IGitWriter.js';
4
+ import type { HaltCode, IHaltStore } from '../contracts/IHaltStore.js';
5
+ import type { ILogger } from '../contracts/ILogger.js';
6
+ import type { IOperationJournal } from '../contracts/IOperationJournal.js';
7
+ import type { IPathQuarantine } from '../contracts/IPathQuarantine.js';
8
+ import type { IOrphanedCommitRecorder } from '../contracts/IStateRecorder.js';
9
+ import type { ConflictKey, PauseReason } from '../contracts/syncTypes.js';
10
+ export interface IntegrateUpstreamInput {
11
+ readonly cycleId: string;
12
+ /** The branch the cycle resolved its upstream for. */
13
+ readonly branchRef: string;
14
+ /** The verified upstream sha to rebase onto. */
15
+ readonly ontoSha: string;
16
+ /** Commits the upstream has that HEAD lacks, for the PULLED line. */
17
+ readonly behind: number;
18
+ readonly knownConflict: ConflictKey | null;
19
+ }
20
+ export interface IntegrateUpstreamDeps {
21
+ readonly git: IGitReader;
22
+ readonly writer: IGitWriter;
23
+ readonly halt: IHaltStore;
24
+ readonly journal: IOperationJournal;
25
+ readonly quarantine: IPathQuarantine;
26
+ readonly logger: ILogger;
27
+ readonly clock: IClock;
28
+ /** Passed to the rebase abort, which reports each commit it orphans (design 14.15). */
29
+ readonly orphans?: IOrphanedCommitRecorder | undefined;
30
+ }
31
+ export type IntegrateUpstreamResult = {
32
+ readonly kind: 'rebased';
33
+ }
34
+ /** Only a rebase that really stopped and whose abort was verified. */
35
+ | {
36
+ readonly kind: 'conflict';
37
+ readonly conflict: ConflictKey;
38
+ } | {
39
+ readonly kind: 'busy';
40
+ }
41
+ /**
42
+ * git refused to start the rebase and left nothing in progress (design 12.7,
43
+ * Decisions 78 and 82); detail is git's stderr. Never aborted or verified.
44
+ */
45
+ | {
46
+ readonly kind: 'not-started';
47
+ readonly detail: string;
48
+ } | {
49
+ readonly kind: 'paused';
50
+ readonly reason: PauseReason;
51
+ } | {
52
+ readonly kind: 'halted';
53
+ readonly code: HaltCode;
54
+ };
55
+ /**
56
+ * The integrate step with its deps bound, the shape the sync cycle consumes. The
57
+ * composition root builds it in a closure over integrateUpstreamController, so
58
+ * the cycle's tests mock one result instead of the rebase's internals.
59
+ */
60
+ export type IntegrateUpstream = (input: IntegrateUpstreamInput) => Promise<IntegrateUpstreamResult>;
61
+ /**
62
+ * Controller: rebase HEAD onto the verified upstream with the excluded
63
+ * paths held aside (design 1.11).
64
+ *
65
+ * 1. head = git.headCommit(); an identical knownConflict logs CONFLICT and returns it (Decision 11).
66
+ * 2. Dirty tracked paths: quarantine.hold; pending returns busy, unsupported pauses QUARANTINE_UNSUPPORTED.
67
+ * 3. From here on in try/finally:
68
+ * a. git.repositoryState() again: operation pauses FOREIGN_OPERATION, indexLocked returns busy,
69
+ * another branch pauses BRANCH_CHANGED.
70
+ * b. journal.begin(entry), then writer.rebase(ontoSha, rebaseReflogAction(entry)):
71
+ * the attempt's token in HEAD's reflog (Decision 104).
72
+ * c. rebased: journal.clear(). refused: journal.clear(), pause FOREIGN_OPERATION, no abort.
73
+ * not-started: journal.clear(), returned with git's stderr in detail; no
74
+ * abort, no verification, no conflict key (Decisions 78, 82).
75
+ * stopped: rebaseOwner first (Decision 104): HEAD's newest start entry not this
76
+ * attempt's token (another action, an earlier token, none) clears the journal and
77
+ * pauses FOREIGN_OPERATION with nothing captured, adopted or aborted. Otherwise
78
+ * Decision 97's adoption, then abort only a rebase whose onto and orig-head are
79
+ * enmetu's, capturing first every write that landed during the stop (step 0,
80
+ * Decision 45) and naming staged writes (step 0b, Decision 105), then verify (no
81
+ * operation, nothing unmerged, HEAD back at head); unverified halts
82
+ * REBASE_ABORT_UNVERIFIED and keeps journal and memento. A branch a commit
83
+ * moved while the rebase ran ends the rebase at its tip instead (Decision
84
+ * 122): verified, that returns busy with no conflict key and no CONFLICT line.
85
+ * d. A throw from a to c before the abort: the same abort-and-verify, ownership
86
+ * included; with nothing in progress and a failed verification, another owner
87
+ * clears the journal and pauses FOREIGN_OPERATION instead of halting.
88
+ * GIT_INDEX_LOCKED and GIT_TIMEOUT return busy without a conflict key, anything
89
+ * else is rethrown.
90
+ * A throw out of abort-and-verify itself (from c or d) is never retried and escapes.
91
+ * finally: quarantine.restore() when paths were held or captured, unless the abort was
92
+ * unverified or threw; failed halts QUARANTINE_RESTORE_FAILED, kept blobs log QUARANTINE_KEPT.
93
+ * 4. rebased logs PULLED with the count.
94
+ */
95
+ export declare function integrateUpstreamController(input: IntegrateUpstreamInput, deps: IntegrateUpstreamDeps): Promise<IntegrateUpstreamResult>;
@@ -0,0 +1,353 @@
1
+ import { EnmetuError } from '../contracts/errors.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, rebaseAbortFailure, recordAbortUnverified, } from '../recovery/enmetuRebaseAbort.js';
6
+ const SHORT_SHA_LENGTH = 7;
7
+ /**
8
+ * Controller: rebase HEAD onto the verified upstream with the excluded
9
+ * paths held aside (design 1.11).
10
+ *
11
+ * 1. head = git.headCommit(); an identical knownConflict logs CONFLICT and returns it (Decision 11).
12
+ * 2. Dirty tracked paths: quarantine.hold; pending returns busy, unsupported pauses QUARANTINE_UNSUPPORTED.
13
+ * 3. From here on in try/finally:
14
+ * a. git.repositoryState() again: operation pauses FOREIGN_OPERATION, indexLocked returns busy,
15
+ * another branch pauses BRANCH_CHANGED.
16
+ * b. journal.begin(entry), then writer.rebase(ontoSha, rebaseReflogAction(entry)):
17
+ * the attempt's token in HEAD's reflog (Decision 104).
18
+ * c. rebased: journal.clear(). refused: journal.clear(), pause FOREIGN_OPERATION, no abort.
19
+ * not-started: journal.clear(), returned with git's stderr in detail; no
20
+ * abort, no verification, no conflict key (Decisions 78, 82).
21
+ * stopped: rebaseOwner first (Decision 104): HEAD's newest start entry not this
22
+ * attempt's token (another action, an earlier token, none) clears the journal and
23
+ * pauses FOREIGN_OPERATION with nothing captured, adopted or aborted. Otherwise
24
+ * Decision 97's adoption, then abort only a rebase whose onto and orig-head are
25
+ * enmetu's, capturing first every write that landed during the stop (step 0,
26
+ * Decision 45) and naming staged writes (step 0b, Decision 105), then verify (no
27
+ * operation, nothing unmerged, HEAD back at head); unverified halts
28
+ * REBASE_ABORT_UNVERIFIED and keeps journal and memento. A branch a commit
29
+ * moved while the rebase ran ends the rebase at its tip instead (Decision
30
+ * 122): verified, that returns busy with no conflict key and no CONFLICT line.
31
+ * d. A throw from a to c before the abort: the same abort-and-verify, ownership
32
+ * included; with nothing in progress and a failed verification, another owner
33
+ * clears the journal and pauses FOREIGN_OPERATION instead of halting.
34
+ * GIT_INDEX_LOCKED and GIT_TIMEOUT return busy without a conflict key, anything
35
+ * else is rethrown.
36
+ * A throw out of abort-and-verify itself (from c or d) is never retried and escapes.
37
+ * finally: quarantine.restore() when paths were held or captured, unless the abort was
38
+ * unverified or threw; failed halts QUARANTINE_RESTORE_FAILED, kept blobs log QUARANTINE_KEPT.
39
+ * 4. rebased logs PULLED with the count.
40
+ */
41
+ export async function integrateUpstreamController(input, deps) {
42
+ const { cycleId, ontoSha } = input;
43
+ // --- Step 1: an identical conflict is reported, not re-run (Decision 11) ---
44
+ const head = await deps.git.headCommit();
45
+ if (head === null) {
46
+ // The cycle only integrates after a divergence against HEAD succeeded.
47
+ throw new EnmetuError('HEAD is unborn; there is nothing to rebase', 'HEAD_UNBORN', cycleId);
48
+ }
49
+ const key = { head, upstream: ontoSha };
50
+ if (input.knownConflict !== null &&
51
+ input.knownConflict.head === head &&
52
+ input.knownConflict.upstream === ontoSha) {
53
+ deps.logger.log({
54
+ level: 'WARN',
55
+ code: 'CONFLICT',
56
+ message: `rebase onto ${shortSha(ontoSha)} already stopped from this HEAD; not re-run, local commits intact`,
57
+ cycleId,
58
+ });
59
+ return { kind: 'conflict', conflict: key };
60
+ }
61
+ // --- Step 2: hold dirty tracked paths aside; untracked ones never block a rebase ---
62
+ const dirty = (await deps.git.status())
63
+ .filter((entry) => entry.index !== '?' &&
64
+ (entry.worktree === 'M' ||
65
+ entry.worktree === 'D' ||
66
+ entry.worktree === 'T'))
67
+ .map((entry) => entry.path);
68
+ let held = false;
69
+ if (dirty.length > 0) {
70
+ const hold = await deps.quarantine.hold(dirty);
71
+ if (hold.kind === 'pending') {
72
+ return { kind: 'busy' };
73
+ }
74
+ if (hold.kind === 'unsupported') {
75
+ return { kind: 'paused', reason: 'QUARANTINE_UNSUPPORTED' };
76
+ }
77
+ held = true;
78
+ }
79
+ // --- Step 3: rebase under try/finally, so a throw never leaves held paths at HEAD ---
80
+ let attempt;
81
+ let restoreFailed = false;
82
+ try {
83
+ attempt = await attemptRebase(input, head, deps);
84
+ }
85
+ finally {
86
+ // An attempt that threw out of abort-and-verify itself has an unknown
87
+ // abort state; it counts like an unverified abort and keeps the memento for the next
88
+ // cycle or the startup recovery, like a verification that failed.
89
+ if (attempt !== undefined &&
90
+ (held || attempt.captured) &&
91
+ !isUnverifiedAbort(attempt)) {
92
+ restoreFailed = !(await restoreHeld(cycleId, deps));
93
+ }
94
+ }
95
+ if (restoreFailed) {
96
+ return { kind: 'halted', code: 'QUARANTINE_RESTORE_FAILED' };
97
+ }
98
+ if (attempt.kind === 'thrown') {
99
+ throw attempt.error;
100
+ }
101
+ // --- Step 4: report what was pulled ---
102
+ if (attempt.result.kind === 'rebased') {
103
+ deps.logger.log({
104
+ level: 'INFO',
105
+ code: 'PULLED',
106
+ message: commits(input.behind),
107
+ cycleId,
108
+ });
109
+ }
110
+ return attempt.result;
111
+ }
112
+ /**
113
+ * Steps 3a to 3d; an error comes back in a 'thrown' attempt only after
114
+ * abort-and-verify. A throw out of abort-and-verify itself escapes: step 3c runs
115
+ * it outside 3d's catch, so it is never run a second time (1.11).
116
+ */
117
+ async function attemptRebase(input, head, deps) {
118
+ const { cycleId, ontoSha } = input;
119
+ const rebased = await rebaseOrRecover(input, head, deps);
120
+ if (rebased.kind !== 'stopped') {
121
+ return rebased;
122
+ }
123
+ // --- Step 3c, stopped: ownership first, then adoption and abort-and-verify ---
124
+ const owner = await attemptOwner(rebased.entry, deps);
125
+ if (owner === 'other') {
126
+ // HEAD's reflog says this attempt did not start the rebase in progress,
127
+ // whatever its orig-head and onto: a person's (Decision 104).
128
+ await deps.journal.clear();
129
+ return paused('FOREIGN_OPERATION', false);
130
+ }
131
+ const startedFrom = await ownRebaseOrigHead(input, head, rebased.entry, deps);
132
+ const abort = await abortAndVerify(input, startedFrom, rebased.entry, deps, owner);
133
+ if (abort.verdict !== 'verified') {
134
+ return verdictResult(abort.verdict, abort.captured);
135
+ }
136
+ if (abort.branchMovedTo !== null) {
137
+ // Not a conflict of this HEAD: the next cycle rebases the moved branch,
138
+ // the commit that moved it included (Decision 122).
139
+ return {
140
+ kind: 'result',
141
+ result: { kind: 'busy' },
142
+ captured: abort.captured,
143
+ };
144
+ }
145
+ const detail = rebased.detail.trim();
146
+ deps.logger.log({
147
+ level: 'WARN',
148
+ code: 'CONFLICT',
149
+ message: `rebase onto ${shortSha(ontoSha)} stopped; aborted and verified, local commits intact${detail === '' ? '' : ` (${detail})`}`,
150
+ cycleId,
151
+ });
152
+ return {
153
+ kind: 'result',
154
+ result: {
155
+ kind: 'conflict',
156
+ conflict: { head: startedFrom, upstream: ontoSha },
157
+ },
158
+ captured: abort.captured,
159
+ };
160
+ }
161
+ /**
162
+ * The HEAD enmetu's stopped rebase started from (Decision 97). Step 3a saw no
163
+ * operation in progress and enmetu's own `git rebase` left the rebase
164
+ * directory, so a rebase onto ontoSha in progress now is enmetu's even when
165
+ * its orig-head is not the HEAD read in step 1: a person's commit landed in
166
+ * between, and git rebased from it. The journal is rewritten with that HEAD
167
+ * first, so startup recovery recognises the rebase too; it keeps the
168
+ * attempt's cycle id and time, so the token recovery rebuilds from it still
169
+ * names this attempt (Decision 104). Any other state keeps step 1's HEAD, and
170
+ * abort-and-verify decides unchanged. Runs only after the ownership check
171
+ * found no other owner.
172
+ */
173
+ async function ownRebaseOrigHead(input, head, entry, deps) {
174
+ const origHead = await deps.git.rebaseOrigHead();
175
+ if (origHead === null ||
176
+ origHead === head ||
177
+ (await deps.git.rebaseOnto()) !== input.ontoSha) {
178
+ return head;
179
+ }
180
+ await deps.journal.begin({
181
+ origHead,
182
+ onto: input.ontoSha,
183
+ cycleId: entry.cycleId,
184
+ at: entry.at,
185
+ });
186
+ return origHead;
187
+ }
188
+ /**
189
+ * Who started the rebase in progress, from HEAD's newest start entry (Decision
190
+ * 104). Without a begun journal entry this attempt ran no `git rebase`, so no
191
+ * start entry can carry its token: someone else's, unless HEAD has no reflog
192
+ * and orig-head and onto decide like before.
193
+ */
194
+ async function attemptOwner(entry, deps) {
195
+ const start = await deps.git.rebaseStart();
196
+ if (entry === null) {
197
+ return start.kind === 'unlogged' ? 'unknown' : 'other';
198
+ }
199
+ return rebaseOwner(start, entry);
200
+ }
201
+ /** Steps 3a, 3b, 3c for rebased and refused, and 3d; a stopped rebase is returned for 3c's abort. */
202
+ async function rebaseOrRecover(input, head, deps) {
203
+ const { cycleId, ontoSha } = input;
204
+ // The entry whose token this attempt's rebase carries; null until 3b.
205
+ let attempted = null;
206
+ try {
207
+ // --- Step 3a: a person may have started something while enmetu fetched ---
208
+ const state = await deps.git.repositoryState();
209
+ if (state.operation !== null) {
210
+ return paused('FOREIGN_OPERATION', false);
211
+ }
212
+ if (state.indexLocked) {
213
+ return { kind: 'result', result: { kind: 'busy' }, captured: false };
214
+ }
215
+ if (state.branchRef !== input.branchRef) {
216
+ return paused('BRANCH_CHANGED', false);
217
+ }
218
+ // --- Step 3b: journal first, so a crash mid-rebase is recognisable at startup ---
219
+ const entry = {
220
+ origHead: head,
221
+ onto: ontoSha,
222
+ cycleId,
223
+ at: isoTime(deps.clock),
224
+ };
225
+ attempted = entry;
226
+ await deps.journal.begin(entry);
227
+ const rebase = await deps.writer.rebase(ontoSha, rebaseReflogAction(entry));
228
+ // --- Step 3c: interpret the rebase ---
229
+ if (rebase.kind === 'rebased') {
230
+ await deps.journal.clear();
231
+ return { kind: 'result', result: { kind: 'rebased' }, captured: false };
232
+ }
233
+ if (rebase.kind === 'refused') {
234
+ // The rebase in progress is a person's; aborting it would destroy their work.
235
+ await deps.journal.clear();
236
+ return paused('FOREIGN_OPERATION', false);
237
+ }
238
+ if (rebase.kind === 'not-started') {
239
+ // Nothing is in progress, so there is nothing to abort or verify, and
240
+ // no conflict: the cycle counts the refusal (Decisions 78, 82).
241
+ await deps.journal.clear();
242
+ return {
243
+ kind: 'result',
244
+ result: { kind: 'not-started', detail: rebase.detail },
245
+ captured: false,
246
+ };
247
+ }
248
+ return { kind: 'stopped', detail: rebase.detail, entry };
249
+ }
250
+ catch (error) {
251
+ // --- Step 3d: whatever threw, enmetu's rebase must not stay in progress ---
252
+ const abort = await abortAndVerify(input, head, attempted, deps);
253
+ if (abort.verdict !== 'verified') {
254
+ return verdictResult(abort.verdict, abort.captured);
255
+ }
256
+ // A lock or a timeout carries no conflict key, so the next cycle retries.
257
+ if (error instanceof EnmetuError &&
258
+ (error.code === 'GIT_INDEX_LOCKED' || error.code === 'GIT_TIMEOUT')) {
259
+ return {
260
+ kind: 'result',
261
+ result: { kind: 'busy' },
262
+ captured: abort.captured,
263
+ };
264
+ }
265
+ return { kind: 'thrown', error, captured: abort.captured };
266
+ }
267
+ }
268
+ /**
269
+ * Aborts a rebase in progress only when it is enmetu's through
270
+ * abortEnmetuRebase, the implementation recovery shares: HEAD's reflog must
271
+ * not name another owner (Decision 104), and orig-head and onto must both
272
+ * match. With no rebase in progress (git refused before it started, or
273
+ * removed its directory) there is nothing to capture or abort, and only the
274
+ * verification runs; when it fails, the owner is asked before any halt,
275
+ * because a start entry that is not this attempt's means enmetu's rebase
276
+ * never checked out and moved nothing (form iii of design 13.2). known is the
277
+ * owner step 3c already read.
278
+ */
279
+ async function abortAndVerify(input, head, entry, deps, known) {
280
+ const target = {
281
+ cycleId: input.cycleId,
282
+ origHead: head,
283
+ onto: input.ontoSha,
284
+ reflogAction: entry === null ? null : rebaseReflogAction(entry),
285
+ };
286
+ const owner = async () => known ?? (await attemptOwner(entry, deps));
287
+ const foreign = async () => {
288
+ await deps.journal.clear();
289
+ return { verdict: 'foreign', captured: false, branchMovedTo: null };
290
+ };
291
+ const origHead = await deps.git.rebaseOrigHead();
292
+ const onto = await deps.git.rebaseOnto();
293
+ if (origHead === null && onto === null) {
294
+ const failure = await rebaseAbortFailure(target, deps);
295
+ if (failure === null) {
296
+ await deps.journal.clear();
297
+ return { verdict: 'verified', captured: false, branchMovedTo: null };
298
+ }
299
+ if ((await owner()) === 'other') {
300
+ return foreign();
301
+ }
302
+ await recordAbortUnverified(target, failure, deps);
303
+ return { verdict: 'unverified', captured: false, branchMovedTo: null };
304
+ }
305
+ if ((await owner()) === 'other' ||
306
+ origHead !== head ||
307
+ onto !== input.ontoSha) {
308
+ return foreign();
309
+ }
310
+ return abortEnmetuRebase(target, deps);
311
+ }
312
+ /** Restores the held paths; false when the restore failed and the halt is set. */
313
+ async function restoreHeld(cycleId, deps) {
314
+ const restore = await deps.quarantine.restore();
315
+ if (restore.kind === 'failed') {
316
+ await recordHalt({
317
+ code: 'QUARANTINE_RESTORE_FAILED',
318
+ message: `restoring the paths held during the rebase failed (${restore.detail}); the quarantine memento is kept`,
319
+ cycleId,
320
+ }, deps);
321
+ return false;
322
+ }
323
+ if (restore.kind === 'restored') {
324
+ logRestoreWarnings(restore, cycleId, deps.logger);
325
+ }
326
+ return true;
327
+ }
328
+ function isUnverifiedAbort(attempt) {
329
+ return (attempt.kind === 'result' &&
330
+ attempt.result.kind === 'halted' &&
331
+ attempt.result.code === 'REBASE_ABORT_UNVERIFIED');
332
+ }
333
+ function verdictResult(verdict, captured) {
334
+ return verdict === 'foreign'
335
+ ? paused('FOREIGN_OPERATION', captured)
336
+ : {
337
+ kind: 'result',
338
+ result: { kind: 'halted', code: 'REBASE_ABORT_UNVERIFIED' },
339
+ captured,
340
+ };
341
+ }
342
+ function paused(reason, captured) {
343
+ return { kind: 'result', result: { kind: 'paused', reason }, captured };
344
+ }
345
+ function commits(count) {
346
+ return count === 1 ? '1 commit' : `${count} commits`;
347
+ }
348
+ function shortSha(sha) {
349
+ return sha.slice(0, SHORT_SHA_LENGTH);
350
+ }
351
+ function isoTime(clock) {
352
+ return new Date(clock.now()).toISOString();
353
+ }