@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.
- package/LICENSE +21 -0
- package/README.md +168 -2
- package/dist/adapters/clock/systemClockAdapter.d.ts +6 -0
- package/dist/adapters/clock/systemClockAdapter.js +12 -0
- package/dist/adapters/fs/nodeFileSystemAdapter.d.ts +18 -0
- package/dist/adapters/fs/nodeFileSystemAdapter.js +230 -0
- package/dist/adapters/git/gitFacade.d.ts +148 -0
- package/dist/adapters/git/gitFacade.js +822 -0
- package/dist/adapters/git/gitLocatorFacade.d.ts +8 -0
- package/dist/adapters/git/gitLocatorFacade.js +72 -0
- package/dist/adapters/git/gitProcessAdapter.d.ts +46 -0
- package/dist/adapters/git/gitProcessAdapter.js +261 -0
- package/dist/adapters/log/nullLogger.d.ts +9 -0
- package/dist/adapters/log/nullLogger.js +10 -0
- package/dist/adapters/log/stderrLoggerAdapter.d.ts +10 -0
- package/dist/adapters/log/stderrLoggerAdapter.js +13 -0
- package/dist/adapters/process/nodeProcessAdapter.d.ts +9 -0
- package/dist/adapters/process/nodeProcessAdapter.js +56 -0
- package/dist/adapters/watch/fsWatchAdapter.d.ts +24 -0
- package/dist/adapters/watch/fsWatchAdapter.js +47 -0
- package/dist/cli/commands/hookCommand.d.ts +28 -0
- package/dist/cli/commands/hookCommand.js +90 -0
- package/dist/cli/commands/resumeCommand.d.ts +48 -0
- package/dist/cli/commands/resumeCommand.js +124 -0
- package/dist/cli/commands/runCommand.d.ts +33 -0
- package/dist/cli/commands/runCommand.js +69 -0
- package/dist/cli/commands/statusCommand.d.ts +71 -0
- package/dist/cli/commands/statusCommand.js +249 -0
- package/dist/cli/exitCodes.d.ts +7 -0
- package/dist/cli/exitCodes.js +39 -0
- package/dist/cli/helpText.d.ts +1 -0
- package/dist/cli/helpText.js +16 -0
- package/dist/cli/main.d.ts +8 -0
- package/dist/cli/main.js +49 -0
- package/dist/cli/parseArgs.d.ts +19 -0
- package/dist/cli/parseArgs.js +106 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +32 -0
- package/dist/commit/commitLocalChangesController.d.ts +132 -0
- package/dist/commit/commitLocalChangesController.js +358 -0
- package/dist/commit/gitIndexLock.d.ts +36 -0
- package/dist/commit/gitIndexLock.js +90 -0
- package/dist/commit/repairStaleIndexController.d.ts +90 -0
- package/dist/commit/repairStaleIndexController.js +150 -0
- package/dist/commit/stageGuardedChanges.d.ts +30 -0
- package/dist/commit/stageGuardedChanges.js +52 -0
- package/dist/contracts/IClock.d.ts +7 -0
- package/dist/contracts/IClock.js +1 -0
- package/dist/contracts/IDaemon.d.ts +26 -0
- package/dist/contracts/IDaemon.js +1 -0
- package/dist/contracts/IFileSystem.d.ts +50 -0
- package/dist/contracts/IFileSystem.js +1 -0
- package/dist/contracts/IGitCommitter.d.ts +60 -0
- package/dist/contracts/IGitCommitter.js +1 -0
- package/dist/contracts/IGitConfigReader.d.ts +5 -0
- package/dist/contracts/IGitConfigReader.js +1 -0
- package/dist/contracts/IGitIndexLock.d.ts +44 -0
- package/dist/contracts/IGitIndexLock.js +1 -0
- package/dist/contracts/IGitReader.d.ts +81 -0
- package/dist/contracts/IGitReader.js +1 -0
- package/dist/contracts/IGitRunner.d.ts +49 -0
- package/dist/contracts/IGitRunner.js +1 -0
- package/dist/contracts/IGitWriter.d.ts +39 -0
- package/dist/contracts/IGitWriter.js +1 -0
- package/dist/contracts/IHaltStore.d.ts +14 -0
- package/dist/contracts/IHaltStore.js +1 -0
- package/dist/contracts/IHookInstaller.d.ts +14 -0
- package/dist/contracts/IHookInstaller.js +1 -0
- package/dist/contracts/IInstanceLock.d.ts +26 -0
- package/dist/contracts/IInstanceLock.js +1 -0
- package/dist/contracts/ILogger.d.ts +11 -0
- package/dist/contracts/ILogger.js +1 -0
- package/dist/contracts/IOperationJournal.d.ts +12 -0
- package/dist/contracts/IOperationJournal.js +1 -0
- package/dist/contracts/IPathQuarantine.d.ts +67 -0
- package/dist/contracts/IPathQuarantine.js +1 -0
- package/dist/contracts/IProcessHost.d.ts +11 -0
- package/dist/contracts/IProcessHost.js +1 -0
- package/dist/contracts/IRepositoryLocator.d.ts +28 -0
- package/dist/contracts/IRepositoryLocator.js +1 -0
- package/dist/contracts/IStateRecorder.d.ts +68 -0
- package/dist/contracts/IStateRecorder.js +1 -0
- package/dist/contracts/IStateStore.d.ts +88 -0
- package/dist/contracts/IStateStore.js +1 -0
- package/dist/contracts/ISyncCycle.d.ts +23 -0
- package/dist/contracts/ISyncCycle.js +1 -0
- package/dist/contracts/ISyncCycleRunner.d.ts +4 -0
- package/dist/contracts/ISyncCycleRunner.js +1 -0
- package/dist/contracts/ISyncScheduler.d.ts +6 -0
- package/dist/contracts/ISyncScheduler.js +1 -0
- package/dist/contracts/ITextSink.d.ts +3 -0
- package/dist/contracts/ITextSink.js +1 -0
- package/dist/contracts/IWatcher.d.ts +9 -0
- package/dist/contracts/IWatcher.js +1 -0
- package/dist/contracts/config.d.ts +20 -0
- package/dist/contracts/config.js +1 -0
- package/dist/contracts/errors.d.ts +39 -0
- package/dist/contracts/errors.js +64 -0
- package/dist/contracts/exitCode.d.ts +10 -0
- package/dist/contracts/exitCode.js +17 -0
- package/dist/contracts/gitTypes.d.ts +113 -0
- package/dist/contracts/gitTypes.js +1 -0
- package/dist/contracts/library.d.ts +35 -0
- package/dist/contracts/library.js +1 -0
- package/dist/contracts/logCodes.d.ts +1 -0
- package/dist/contracts/logCodes.js +1 -0
- package/dist/contracts/runtime.d.ts +16 -0
- package/dist/contracts/runtime.js +1 -0
- package/dist/contracts/syncTypes.d.ts +75 -0
- package/dist/contracts/syncTypes.js +6 -0
- package/dist/core/commit/commitMemory.d.ts +3 -0
- package/dist/core/commit/commitMemory.js +9 -0
- package/dist/core/commit/commitMessage.d.ts +6 -0
- package/dist/core/commit/commitMessage.js +12 -0
- package/dist/core/config/parseConfig.d.ts +5 -0
- package/dist/core/config/parseConfig.js +196 -0
- package/dist/core/content/contentRules.d.ts +11 -0
- package/dist/core/content/contentRules.js +50 -0
- package/dist/core/errors/errorMessage.d.ts +6 -0
- package/dist/core/errors/errorMessage.js +14 -0
- package/dist/core/errors/withCycleId.d.ts +7 -0
- package/dist/core/errors/withCycleId.js +38 -0
- package/dist/core/git/enmetuRefs.d.ts +9 -0
- package/dist/core/git/enmetuRefs.js +22 -0
- package/dist/core/git/porcelain.d.ts +51 -0
- package/dist/core/git/porcelain.js +219 -0
- package/dist/core/git/rebaseOwnership.d.ts +28 -0
- package/dist/core/git/rebaseOwnership.js +29 -0
- package/dist/core/git/stoppedRebaseWrites.d.ts +10 -0
- package/dist/core/git/stoppedRebaseWrites.js +22 -0
- package/dist/core/halt/resumeHint.d.ts +6 -0
- package/dist/core/halt/resumeHint.js +9 -0
- package/dist/core/log/formatLogLine.d.ts +3 -0
- package/dist/core/log/formatLogLine.js +11 -0
- package/dist/core/quarantine/keptBlobMessage.d.ts +5 -0
- package/dist/core/quarantine/keptBlobMessage.js +8 -0
- package/dist/core/quarantine/restoreWarnings.d.ts +16 -0
- package/dist/core/quarantine/restoreWarnings.js +36 -0
- package/dist/core/schedule/schedulerStep.d.ts +63 -0
- package/dist/core/schedule/schedulerStep.js +144 -0
- package/dist/core/state/stateGuards.d.ts +21 -0
- package/dist/core/state/stateGuards.js +295 -0
- package/dist/core/state/stateLiveness.d.ts +10 -0
- package/dist/core/state/stateLiveness.js +21 -0
- package/dist/core/state/stateRecordStep.d.ts +28 -0
- package/dist/core/state/stateRecordStep.js +203 -0
- package/dist/core/status/conflictGuide.d.ts +30 -0
- package/dist/core/status/conflictGuide.js +86 -0
- package/dist/core/status/orphanedCommits.d.ts +15 -0
- package/dist/core/status/orphanedCommits.js +24 -0
- package/dist/core/status/statusHeadline.d.ts +46 -0
- package/dist/core/status/statusHeadline.js +130 -0
- package/dist/core/watch/watchFilter.d.ts +2 -0
- package/dist/core/watch/watchFilter.js +34 -0
- package/dist/daemon/daemon.d.ts +55 -0
- package/dist/daemon/daemon.js +277 -0
- package/dist/enmetuInstances.d.ts +65 -0
- package/dist/enmetuInstances.js +333 -0
- package/dist/halt/haltGuardGitWriter.d.ts +23 -0
- package/dist/halt/haltGuardGitWriter.js +61 -0
- package/dist/halt/haltStore.d.ts +13 -0
- package/dist/halt/haltStore.js +49 -0
- package/dist/halt/recordHalt.d.ts +19 -0
- package/dist/halt/recordHalt.js +20 -0
- package/dist/hooks/hookScript.d.ts +22 -0
- package/dist/hooks/hookScript.js +52 -0
- package/dist/hooks/nullHookInstaller.d.ts +5 -0
- package/dist/hooks/nullHookInstaller.js +6 -0
- package/dist/hooks/preCommitCheckController.d.ts +23 -0
- package/dist/hooks/preCommitCheckController.js +22 -0
- package/dist/hooks/preCommitHookInstaller.d.ts +32 -0
- package/dist/hooks/preCommitHookInstaller.js +113 -0
- package/dist/index.d.ts +18 -0
- package/dist/index.js +38 -0
- package/dist/library/enmetuLibrary.d.ts +16 -0
- package/dist/library/enmetuLibrary.js +62 -0
- package/dist/library/readStateController.d.ts +28 -0
- package/dist/library/readStateController.js +32 -0
- package/dist/library/requestedStopProcessHost.d.ts +28 -0
- package/dist/library/requestedStopProcessHost.js +58 -0
- package/dist/lock/instanceLock.d.ts +26 -0
- package/dist/lock/instanceLock.js +125 -0
- package/dist/preflight/repositoryPreflight.d.ts +20 -0
- package/dist/preflight/repositoryPreflight.js +42 -0
- package/dist/quarantine/pathQuarantine.d.ts +113 -0
- package/dist/quarantine/pathQuarantine.js +504 -0
- package/dist/recovery/enmetuRebaseAbort.d.ts +108 -0
- package/dist/recovery/enmetuRebaseAbort.js +302 -0
- package/dist/recovery/operationJournal.d.ts +13 -0
- package/dist/recovery/operationJournal.js +37 -0
- package/dist/recovery/recoveryController.d.ts +48 -0
- package/dist/recovery/recoveryController.js +112 -0
- package/dist/state/stateFileStore.d.ts +18 -0
- package/dist/state/stateFileStore.js +73 -0
- package/dist/state/stateRecorder.d.ts +26 -0
- package/dist/state/stateRecorder.js +69 -0
- package/dist/state/stateRecordingSyncCycle.d.ts +24 -0
- package/dist/state/stateRecordingSyncCycle.js +71 -0
- package/dist/sync/hookAbortingSyncScheduler.d.ts +16 -0
- package/dist/sync/hookAbortingSyncScheduler.js +27 -0
- package/dist/sync/integrateUpstreamController.d.ts +95 -0
- package/dist/sync/integrateUpstreamController.js +353 -0
- package/dist/sync/syncCycleController.d.ts +71 -0
- package/dist/sync/syncCycleController.js +345 -0
- package/dist/sync/syncCycleRunner.d.ts +24 -0
- package/dist/sync/syncCycleRunner.js +169 -0
- package/dist/sync/syncScheduler.d.ts +32 -0
- package/dist/sync/syncScheduler.js +97 -0
- 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
|
+
}
|