@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,32 @@
1
+ import { PreflightError } from '../contracts/errors.js';
2
+ import { stateLiveness } from '../core/state/stateLiveness.js';
3
+ /**
4
+ * Controller:
5
+ * 1. locator.locate(startDir); not found throws PreflightError
6
+ * NOT_A_REPOSITORY, bare throws BARE_REPOSITORY.
7
+ * 2. state.read(), lock.inspect(), state.read() again: a daemon that writes
8
+ * stopped and releases between the first read and the inspect would
9
+ * otherwise look crashed; the second record is judged with the holder
10
+ * (Decision 86).
11
+ * 3. { liveness: stateLiveness(second record, holder), record: second record }.
12
+ * Reads only: no git command beyond the locator, no network.
13
+ */
14
+ export async function readStateController(input, deps) {
15
+ // --- Step 1: the repository root through git (D9) ---
16
+ const location = await deps.locator.locate(input.startDir);
17
+ if (location.kind === 'not-a-repository') {
18
+ throw new PreflightError(`${input.startDir} is not inside a git work tree`, 'NOT_A_REPOSITORY');
19
+ }
20
+ if (location.kind === 'bare') {
21
+ throw new PreflightError(`${input.startDir} is a bare repository; enmetu needs a work tree`, 'BARE_REPOSITORY');
22
+ }
23
+ const { state, lock } = deps.openSources(location.paths);
24
+ // --- Step 2: state, lock, state again (Decision 86) ---
25
+ // The first read only orders the inspect after any write already under
26
+ // way; the second record is the one judged.
27
+ await state.read();
28
+ const holder = await lock.inspect();
29
+ const record = await state.read();
30
+ // --- Step 3: liveness of the second record ---
31
+ return { liveness: stateLiveness(record, holder), record };
32
+ }
@@ -0,0 +1,28 @@
1
+ import type { IProcessHost, StopSignal } from '../contracts/IProcessHost.js';
2
+ type StopHandler = (signal: StopSignal) => void;
3
+ /**
4
+ * Decorator over IProcessHost for enmetu inside a host program (design
5
+ * 12.5): pid, hostname and isAlive come from the wrapped host; stop signals
6
+ * come from requestStop() instead of the operating system, so the library
7
+ * installs no signal handler in the host process and never exits it.
8
+ * requestStop() calls each subscribed handler once with 'SIGTERM' (later calls
9
+ * do nothing, so the daemon's second-signal path is unreachable). The request
10
+ * is latched: a handler subscribed after a stop was requested is called at once
11
+ * from onStopSignal, so a stop() during the daemon's startup (before its step
12
+ * 7) is never lost; the scheduler already ends a run whose stop came before it
13
+ * started (Decision 87). exitNow() throws instead of exiting.
14
+ */
15
+ export declare class RequestedStopProcessHost implements IProcessHost {
16
+ private readonly inner;
17
+ private readonly handlers;
18
+ private stopRequested;
19
+ constructor(inner: IProcessHost);
20
+ get pid(): number;
21
+ get hostname(): string;
22
+ isAlive(pid: number): boolean;
23
+ onStopSignal(handler: StopHandler): () => void;
24
+ exitNow(code: number): never;
25
+ /** Asks the daemon to stop after the running cycle. */
26
+ requestStop(): void;
27
+ }
28
+ export {};
@@ -0,0 +1,58 @@
1
+ /** The signal the CLI's first stop would carry; the daemon treats it like a first SIGTERM. */
2
+ const REQUESTED_SIGNAL = 'SIGTERM';
3
+ /**
4
+ * Decorator over IProcessHost for enmetu inside a host program (design
5
+ * 12.5): pid, hostname and isAlive come from the wrapped host; stop signals
6
+ * come from requestStop() instead of the operating system, so the library
7
+ * installs no signal handler in the host process and never exits it.
8
+ * requestStop() calls each subscribed handler once with 'SIGTERM' (later calls
9
+ * do nothing, so the daemon's second-signal path is unreachable). The request
10
+ * is latched: a handler subscribed after a stop was requested is called at once
11
+ * from onStopSignal, so a stop() during the daemon's startup (before its step
12
+ * 7) is never lost; the scheduler already ends a run whose stop came before it
13
+ * started (Decision 87). exitNow() throws instead of exiting.
14
+ */
15
+ export class RequestedStopProcessHost {
16
+ inner;
17
+ handlers = new Set();
18
+ stopRequested = false;
19
+ constructor(inner) {
20
+ this.inner = inner;
21
+ }
22
+ get pid() {
23
+ return this.inner.pid;
24
+ }
25
+ get hostname() {
26
+ return this.inner.hostname;
27
+ }
28
+ isAlive(pid) {
29
+ return this.inner.isAlive(pid);
30
+ }
31
+ onStopSignal(handler) {
32
+ if (this.stopRequested) {
33
+ // Delivered now and never stored: the request is spent for this handler,
34
+ // and a stored handler could only receive a second, forbidden call.
35
+ handler(REQUESTED_SIGNAL);
36
+ return () => { };
37
+ }
38
+ this.handlers.add(handler);
39
+ return () => {
40
+ this.handlers.delete(handler);
41
+ };
42
+ }
43
+ exitNow(code) {
44
+ throw new Error(`enmetu inside a host program never exits the process (exit code ${code} requested)`);
45
+ }
46
+ /** Asks the daemon to stop after the running cycle. */
47
+ requestStop() {
48
+ if (this.stopRequested) {
49
+ return;
50
+ }
51
+ this.stopRequested = true;
52
+ const subscribed = [...this.handlers];
53
+ this.handlers.clear();
54
+ for (const handler of subscribed) {
55
+ handler(REQUESTED_SIGNAL);
56
+ }
57
+ }
58
+ }
@@ -0,0 +1,26 @@
1
+ import type { IClock } from '../contracts/IClock.js';
2
+ import type { IFileSystem } from '../contracts/IFileSystem.js';
3
+ import type { IInstanceLock, LockAcquisition, LockHolder } from '../contracts/IInstanceLock.js';
4
+ import type { IProcessHost } from '../contracts/IProcessHost.js';
5
+ export declare const LOCK_FILE_NAME = "instance.lock";
6
+ /**
7
+ * Service: <stateDir>/instance.lock (D13, Decision 13). Created with
8
+ * createExclusive; a holder whose pid is dead is taken over by an atomic
9
+ * rename to instance.lock.stale-<token>, so exactly one of two concurrent
10
+ * takers wins. release() compares the per-process token, not pid or hostname.
11
+ */
12
+ export declare class InstanceLock implements IInstanceLock {
13
+ private readonly fs;
14
+ private readonly processHost;
15
+ private readonly clock;
16
+ private readonly stateDir;
17
+ private readonly token;
18
+ constructor(fs: IFileSystem, processHost: IProcessHost, clock: IClock, stateDir: string, token: string);
19
+ acquire(): Promise<LockAcquisition>;
20
+ release(): Promise<void>;
21
+ inspect(): Promise<LockHolder | null>;
22
+ private examineExisting;
23
+ /** Restores a fresh lock renamed by mistake and reports its holder. */
24
+ private putBack;
25
+ private lockPath;
26
+ }
@@ -0,0 +1,125 @@
1
+ import { join } from 'node:path';
2
+ import { parseLockRecord } from '../core/state/stateGuards.js';
3
+ export const LOCK_FILE_NAME = 'instance.lock';
4
+ /**
5
+ * Service: <stateDir>/instance.lock (D13, Decision 13). Created with
6
+ * createExclusive; a holder whose pid is dead is taken over by an atomic
7
+ * rename to instance.lock.stale-<token>, so exactly one of two concurrent
8
+ * takers wins. release() compares the per-process token, not pid or hostname.
9
+ */
10
+ export class InstanceLock {
11
+ fs;
12
+ processHost;
13
+ clock;
14
+ stateDir;
15
+ token;
16
+ constructor(fs, processHost, clock, stateDir, token) {
17
+ this.fs = fs;
18
+ this.processHost = processHost;
19
+ this.clock = clock;
20
+ this.stateDir = stateDir;
21
+ this.token = token;
22
+ }
23
+ async acquire() {
24
+ const content = serialiseLock({
25
+ pid: this.processHost.pid,
26
+ hostname: this.processHost.hostname,
27
+ startedAt: new Date(this.clock.now()).toISOString(),
28
+ token: this.token,
29
+ });
30
+ let tookOverStale = false;
31
+ // Every pass that does not finish was caused by another process changing
32
+ // the lock file in between (it vanished, or another taker renamed it
33
+ // first); each such change is atomic, so the loop converges.
34
+ for (;;) {
35
+ if (await this.fs.createExclusive(this.lockPath(), content)) {
36
+ return { kind: 'acquired', tookOverStale };
37
+ }
38
+ const step = await this.examineExisting();
39
+ if (step.kind === 'done') {
40
+ return step.result;
41
+ }
42
+ tookOverStale = tookOverStale || step.tookOverStale;
43
+ }
44
+ }
45
+ async release() {
46
+ const text = await this.fs.readText(this.lockPath());
47
+ const record = text === null ? null : parseLockRecord(text);
48
+ // The token, not pid or hostname: macOS renames the host with the network,
49
+ // and only the process that wrote this token may remove the file.
50
+ if (record !== null && record.token === this.token) {
51
+ await this.fs.remove(this.lockPath());
52
+ }
53
+ }
54
+ async inspect() {
55
+ const text = await this.fs.readText(this.lockPath());
56
+ const record = text === null ? null : parseLockRecord(text);
57
+ if (record === null || !this.processHost.isAlive(record.pid)) {
58
+ return null;
59
+ }
60
+ return toHolder(record);
61
+ }
62
+ async examineExisting() {
63
+ const lockPath = this.lockPath();
64
+ const staleText = await this.fs.readText(lockPath);
65
+ if (staleText === null) {
66
+ return { kind: 'retry', tookOverStale: false };
67
+ }
68
+ const stale = parseLockRecord(staleText);
69
+ if (stale === null) {
70
+ // Never removed: it may be a lock from a newer enmetu or a person's
71
+ // file, and the LOCKED line names it for the person to inspect.
72
+ return { kind: 'done', result: { kind: 'unreadable', path: lockPath } };
73
+ }
74
+ // Liveness by pid alone; the hostname is reported, never compared.
75
+ if (this.processHost.isAlive(stale.pid)) {
76
+ return { kind: 'done', result: { kind: 'held', holder: toHolder(stale) } };
77
+ }
78
+ const asidePath = `${lockPath}.stale-${this.token}`;
79
+ if (!(await this.fs.rename(lockPath, asidePath))) {
80
+ return { kind: 'retry', tookOverStale: false };
81
+ }
82
+ // Between the read and the rename another taker may have replaced the
83
+ // stale lock with its own; the renamed bytes tell which file was moved.
84
+ const movedText = await this.fs.readText(asidePath);
85
+ if (movedText === staleText) {
86
+ await this.fs.remove(asidePath);
87
+ return { kind: 'retry', tookOverStale: true };
88
+ }
89
+ if (movedText === null) {
90
+ return { kind: 'retry', tookOverStale: false };
91
+ }
92
+ return { kind: 'done', result: await this.putBack(asidePath, movedText) };
93
+ }
94
+ /** Restores a fresh lock renamed by mistake and reports its holder. */
95
+ async putBack(asidePath, movedText) {
96
+ const lockPath = this.lockPath();
97
+ await this.fs.createExclusive(lockPath, movedText);
98
+ await this.fs.remove(asidePath);
99
+ const fresh = parseLockRecord(movedText);
100
+ if (fresh === null) {
101
+ return { kind: 'unreadable', path: lockPath };
102
+ }
103
+ return { kind: 'held', holder: toHolder(fresh) };
104
+ }
105
+ lockPath() {
106
+ return join(this.stateDir, LOCK_FILE_NAME);
107
+ }
108
+ }
109
+ /** Fields in LockRecord order, so the file is byte-stable. */
110
+ function serialiseLock(record) {
111
+ const ordered = {
112
+ pid: record.pid,
113
+ hostname: record.hostname,
114
+ startedAt: record.startedAt,
115
+ token: record.token,
116
+ };
117
+ return `${JSON.stringify(ordered)}\n`;
118
+ }
119
+ function toHolder(record) {
120
+ return {
121
+ pid: record.pid,
122
+ hostname: record.hostname,
123
+ startedAt: record.startedAt,
124
+ };
125
+ }
@@ -0,0 +1,20 @@
1
+ import type { Upstream } from '../contracts/gitTypes.js';
2
+ import type { IGitReader } from '../contracts/IGitReader.js';
3
+ export interface PreflightInput {
4
+ /** Always 'startup'. */
5
+ readonly cycleId: string;
6
+ }
7
+ export interface PreflightDeps {
8
+ readonly git: IGitReader;
9
+ }
10
+ /** The upstream STARTED reports. */
11
+ export type PreflightResult = Upstream;
12
+ /**
13
+ * Controller: startup eligibility (design 1.16, Decision 28). Throws
14
+ * PreflightError carrying the cycle id, refusing in order:
15
+ * 1. OPERATION_IN_PROGRESS, 2. DETACHED_HEAD, 3. HEAD_UNBORN, 4. NO_UPSTREAM,
16
+ * 5. UPSTREAM_NOT_REMOTE (remote '.'), 6. IDENTITY_MISSING.
17
+ * During a run the same conditions pause the cycle instead; refusing at
18
+ * startup gives the person the reason while they are still looking.
19
+ */
20
+ export declare function repositoryPreflight(input: PreflightInput, deps: PreflightDeps): Promise<PreflightResult>;
@@ -0,0 +1,42 @@
1
+ import { PreflightError } from '../contracts/errors.js';
2
+ /** A remote named '.' is the local repository itself: there is nothing to sync with. */
3
+ const LOCAL_REMOTE = '.';
4
+ /**
5
+ * Controller: startup eligibility (design 1.16, Decision 28). Throws
6
+ * PreflightError carrying the cycle id, refusing in order:
7
+ * 1. OPERATION_IN_PROGRESS, 2. DETACHED_HEAD, 3. HEAD_UNBORN, 4. NO_UPSTREAM,
8
+ * 5. UPSTREAM_NOT_REMOTE (remote '.'), 6. IDENTITY_MISSING.
9
+ * During a run the same conditions pause the cycle instead; refusing at
10
+ * startup gives the person the reason while they are still looking.
11
+ */
12
+ export async function repositoryPreflight(input, deps) {
13
+ const { cycleId } = input;
14
+ // --- Step 1: a person's rebase, merge, cherry-pick, revert or bisect ---
15
+ const state = await deps.git.repositoryState();
16
+ if (state.operation !== null) {
17
+ throw new PreflightError(`a ${state.operation} is in progress; finish or abort it, then start enmetu again`, 'OPERATION_IN_PROGRESS', cycleId);
18
+ }
19
+ // --- Step 2: a checked-out branch ---
20
+ const branchRef = state.branchRef;
21
+ if (branchRef === null) {
22
+ throw new PreflightError('HEAD is detached; check out a branch with an upstream', 'DETACHED_HEAD', cycleId);
23
+ }
24
+ // --- Step 3: at least one commit ---
25
+ if ((await deps.git.headCommit()) === null) {
26
+ throw new PreflightError(`${branchRef} has no commit yet; make the first commit by hand`, 'HEAD_UNBORN', cycleId);
27
+ }
28
+ // --- Step 4: an upstream for the checked-out branch (D6) ---
29
+ const upstream = await deps.git.upstream(branchRef);
30
+ if (upstream === null) {
31
+ throw new PreflightError(`${branchRef} has no upstream; set one with git branch --set-upstream-to`, 'NO_UPSTREAM', cycleId);
32
+ }
33
+ // --- Step 5: the upstream lives on a remote ---
34
+ if (upstream.remote === LOCAL_REMOTE) {
35
+ throw new PreflightError(`the upstream of ${branchRef} is the local branch ${upstream.remoteRef}, not a remote`, 'UPSTREAM_NOT_REMOTE', cycleId);
36
+ }
37
+ // --- Step 6: a committer identity, without which every commit fails ---
38
+ if (!(await deps.git.hasCommitterIdentity())) {
39
+ throw new PreflightError('git has no committer identity; set user.name and user.email', 'IDENTITY_MISSING', cycleId);
40
+ }
41
+ return upstream;
42
+ }
@@ -0,0 +1,113 @@
1
+ import type { IFileSystem } from '../contracts/IFileSystem.js';
2
+ import type { IGitReader } from '../contracts/IGitReader.js';
3
+ import type { IGitWriter } from '../contracts/IGitWriter.js';
4
+ import type { HoldResult, IPathQuarantine, RestoreResult } from '../contracts/IPathQuarantine.js';
5
+ export declare const QUARANTINE_FILE_NAME = "quarantine.json";
6
+ /** Temp files of restored worktree paths, outside the tree so a crash cannot leave one to be committed. */
7
+ export declare const QUARANTINE_TEMP_DIR_NAME = "tmp";
8
+ /** How often a restore judges an entry again when the file changed between its decision and its write. */
9
+ export declare const MAX_RESTORE_REJUDGES = 2;
10
+ export interface QuarantinePaths {
11
+ readonly root: string;
12
+ readonly stateDir: string;
13
+ }
14
+ /**
15
+ * Service and Memento caretaker (D4): holds tracked paths that are still
16
+ * dirty after the commit step aside for the duration of a rebase, without a
17
+ * stash, and captures writes that land while enmetu's rebase is stopped
18
+ * before the abort resets them (Decision 45). The memento
19
+ * (<stateDir>/quarantine.json) is persisted before the tree changes and
20
+ * removed only after every entry was handled.
21
+ */
22
+ export declare class PathQuarantine implements IPathQuarantine {
23
+ private readonly git;
24
+ private readonly writer;
25
+ private readonly fs;
26
+ private readonly paths;
27
+ constructor(git: IGitReader, writer: IGitWriter, fs: IFileSystem, paths: QuarantinePaths);
28
+ hold(paths: readonly string[]): Promise<HoldResult>;
29
+ extend(paths: readonly string[]): Promise<HoldResult>;
30
+ /**
31
+ * git's checkout writes the index's version over a file whatever the file
32
+ * holds, so a write that landed after storeEntries read it would be gone
33
+ * without a record. Each file is read once more right before the checkout;
34
+ * one whose bytes changed (or that appeared or went) is stored again and the
35
+ * memento rewritten first (design 13.11, Decision 114). What stays open is
36
+ * the time between this read and git's own write, the few milliseconds of
37
+ * one spawn. A path that is no longer a regular file keeps its entry.
38
+ */
39
+ private storeAgainIfWritten;
40
+ restore(): Promise<RestoreResult>;
41
+ hasPending(): Promise<boolean>;
42
+ /**
43
+ * "Written during the hold" is judged by the working tree alone: the held
44
+ * version goes back when the file still holds what enmetu left there. The
45
+ * index is never the judge, because a person may stage the held path during
46
+ * the hold (git add, git apply --cached) without touching the file, and
47
+ * keeping the held bytes aside then would take the unstaged part off disk.
48
+ */
49
+ private restoreEntry;
50
+ /** The file holds what the hold's checkout wrote, or HEAD's version (what a rebase or its abort wrote). */
51
+ private stillAsLeft;
52
+ /**
53
+ * The file still holds what enmetu left (Decision 99). When HEAD's version
54
+ * of the path is no longer heldOver, a rebase changed the path under the
55
+ * hold and the held bytes are based on the old version: written back as
56
+ * they are, enmetu's next commit would undo that change. Then the clean
57
+ * three-way merge of HEAD's version and the held bytes, base heldOver, goes
58
+ * back, only where no filter applies (raw hash equals filtered hash) and
59
+ * only while the file is still unchanged; a newer write wins and the held
60
+ * bytes are kept in a blob. Without a clean merge (a conflict, binary
61
+ * input, a filter, a deletion on either side) the held version goes back
62
+ * and the path is reported, so the undo is never silent. seen is the file
63
+ * the way the decision read it (observe); every write-back is skipped when the
64
+ * file changed since.
65
+ */
66
+ private putBackOnHead;
67
+ /**
68
+ * Writes the held version back only while the file is still what the
69
+ * decision saw. A write that landed since (a person's editor saving
70
+ * during the restore's own spawns) is judged again from the top, at most
71
+ * MAX_RESTORE_REJUDGES times; after that the write wins and the held bytes
72
+ * stay a blob, so a restore never overwrites what it did not judge. The
73
+ * held bytes are read before the check, so no spawn runs between the check
74
+ * and the write (Decision 117).
75
+ */
76
+ private putBackUnlessChanged;
77
+ /**
78
+ * Writes content over the file (null: removes it) only while the file is
79
+ * what the decision saw (seen), and names a write that lands after that
80
+ * check (design 13.11, Decision 117). No git command can write a file only
81
+ * while it is unchanged, so the version being replaced is kept under a
82
+ * second name in the same step that replaces it:
83
+ *
84
+ * 1. content is written and flushed to a staged file first;
85
+ * 2. the file is checked against seen (one hashFile spawn);
86
+ * 3. link(2) gives the file's current version a second name, then rename(2)
87
+ * puts the staged file in its place (a removal is one rename(2) to the
88
+ * second name);
89
+ * 4. the second name is read: a version other than seen was written after
90
+ * step 2, and is stored in a blob and reported, never lost unrecorded.
91
+ *
92
+ * What stays open is enmetu's own: a save that replaces the file by
93
+ * rename(2) between the link and the rename of step 3 (two consecutive
94
+ * system calls), and a write into the replaced version through a file
95
+ * descriptor opened before step 3 that lands after step 4's read. Where the
96
+ * state directory is on another file system than the working tree (a
97
+ * linked worktree of a repository on another volume), or the file is not a
98
+ * regular file, link(2) cannot keep it and the plain write runs, with the
99
+ * window of steps 2 and 3.
100
+ */
101
+ private writeOver;
102
+ /** The file's raw blob id, or a marker for an absent path or one that is not a regular file. */
103
+ private observe;
104
+ /** Three-way merge of HEAD's blob with the held bytes on the base the hold left; null unless clean. */
105
+ private mergeOntoHead;
106
+ /** Three-way merge of a file with the held bytes on the base the hold left; null unless clean. */
107
+ private mergeHeld;
108
+ private tempDir;
109
+ private inspect;
110
+ private storeEntries;
111
+ private writeMemento;
112
+ private mementoPath;
113
+ }