@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.
- package/LICENSE +21 -0
- package/README.md +209 -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 +75 -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 +29 -0
- package/dist/cli/commands/hookCommand.js +92 -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 +34 -0
- package/dist/cli/commands/runCommand.js +79 -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 +38 -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 +357 -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 +32 -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 +26 -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 +8 -0
- package/dist/core/config/parseConfig.js +176 -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 +52 -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 +332 -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 +123 -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,92 @@
|
|
|
1
|
+
import { join } from 'node:path';
|
|
2
|
+
import { ConfigError, PreflightError } from '../../contracts/errors.js';
|
|
3
|
+
import { EXIT } from '../../contracts/exitCode.js';
|
|
4
|
+
import { CONFIG_FILE_NAME, DEFAULT_CONFIG, parseConfig, } from '../../core/config/parseConfig.js';
|
|
5
|
+
import { preCommitCheckController } from '../../hooks/preCommitCheckController.js';
|
|
6
|
+
const HOOK_CYCLE_ID = 'hook';
|
|
7
|
+
/**
|
|
8
|
+
* Controller:
|
|
9
|
+
* 1. locator.locate(startDir); anything but found: notice, exit 0 (the hook fails open).
|
|
10
|
+
* 2. parseConfig of <root>/enmetu.config.json; a ConfigError prints a notice and
|
|
11
|
+
* applies the defaults, so a broken config never blocks the commit that fixes it.
|
|
12
|
+
* Unknown keys are ignored without a word: the daemon names them at start (design 14.17).
|
|
13
|
+
* 3. preCommitCheckController with the config's skipJsonCheck.
|
|
14
|
+
* 4. Violations: print the refusal of design 1.18 to stderr, exit 1. None: print nothing, exit 0.
|
|
15
|
+
*/
|
|
16
|
+
export async function hookCommand(input, deps) {
|
|
17
|
+
// --- Step 1: the repository; a hook is a net, so anything odd lets the commit through ---
|
|
18
|
+
const location = await locateOrReason(input.startDir, deps);
|
|
19
|
+
if (location.kind !== 'found') {
|
|
20
|
+
deps.stderr.write(`enmetu: pre-commit check skipped (${location.reason})\n`);
|
|
21
|
+
return { exitCode: EXIT.OK };
|
|
22
|
+
}
|
|
23
|
+
const { paths } = location;
|
|
24
|
+
// --- Step 2: the config at the root; defaults when it is broken ---
|
|
25
|
+
const config = await configForCheck(paths.root, deps);
|
|
26
|
+
// --- Step 3: the shared content rules over the staged blobs (D11) ---
|
|
27
|
+
const { violations } = await preCommitCheckController({ cycleId: HOOK_CYCLE_ID }, { git: deps.openGit(paths), skipJsonCheck: config.skipJsonCheck });
|
|
28
|
+
// --- Step 4: the refusal, or silence ---
|
|
29
|
+
if (violations.length === 0) {
|
|
30
|
+
return { exitCode: EXIT.OK };
|
|
31
|
+
}
|
|
32
|
+
deps.stderr.write(refusalText(violations));
|
|
33
|
+
// Git only tells zero from non-zero for hooks (Decision 34).
|
|
34
|
+
return { exitCode: EXIT.FAILURE };
|
|
35
|
+
}
|
|
36
|
+
async function locateOrReason(startDir, deps) {
|
|
37
|
+
let location;
|
|
38
|
+
try {
|
|
39
|
+
location = await deps.locator.locate(startDir);
|
|
40
|
+
}
|
|
41
|
+
catch (error) {
|
|
42
|
+
if (error instanceof PreflightError) {
|
|
43
|
+
return { kind: 'skipped', reason: error.message };
|
|
44
|
+
}
|
|
45
|
+
throw error;
|
|
46
|
+
}
|
|
47
|
+
switch (location.kind) {
|
|
48
|
+
case 'found':
|
|
49
|
+
return location;
|
|
50
|
+
case 'bare':
|
|
51
|
+
return { kind: 'skipped', reason: `${startDir} is a bare repository` };
|
|
52
|
+
case 'not-a-repository':
|
|
53
|
+
return {
|
|
54
|
+
kind: 'skipped',
|
|
55
|
+
reason: `${startDir} is not inside a git work tree`,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
async function configForCheck(root, deps) {
|
|
60
|
+
try {
|
|
61
|
+
return parseConfig(await deps.fs.readText(join(root, CONFIG_FILE_NAME)))
|
|
62
|
+
.config;
|
|
63
|
+
}
|
|
64
|
+
catch (error) {
|
|
65
|
+
if (!(error instanceof ConfigError)) {
|
|
66
|
+
throw error;
|
|
67
|
+
}
|
|
68
|
+
deps.stderr.write(`enmetu: ignoring ${CONFIG_FILE_NAME} (${error.code}: ${error.message}); using the defaults for this check\n`);
|
|
69
|
+
return DEFAULT_CONFIG;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
function refusalText(violations) {
|
|
73
|
+
const count = violations.length;
|
|
74
|
+
const files = count === 1
|
|
75
|
+
? '1 staged file is not fit to commit'
|
|
76
|
+
: `${count} staged files are not fit to commit`;
|
|
77
|
+
return [
|
|
78
|
+
`enmetu: commit refused, ${files}:`,
|
|
79
|
+
...violations.map((violation) => ` ${describeViolation(violation)}`),
|
|
80
|
+
'Resolve the conflict or fix the file, then stage it again.',
|
|
81
|
+
'To commit anyway: git commit --no-verify',
|
|
82
|
+
'',
|
|
83
|
+
].join('\n');
|
|
84
|
+
}
|
|
85
|
+
function describeViolation(violation) {
|
|
86
|
+
switch (violation.rule) {
|
|
87
|
+
case 'CONFLICT_MARKERS':
|
|
88
|
+
return `${violation.path}: conflict markers`;
|
|
89
|
+
case 'INVALID_JSON':
|
|
90
|
+
return `${violation.path}: is not valid JSON (${violation.detail})`;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { type ExitCode } from '../../contracts/exitCode.js';
|
|
2
|
+
import type { IGitReader } from '../../contracts/IGitReader.js';
|
|
3
|
+
import type { IGitWriter } from '../../contracts/IGitWriter.js';
|
|
4
|
+
import type { IHaltStore } from '../../contracts/IHaltStore.js';
|
|
5
|
+
import type { IInstanceLock } from '../../contracts/IInstanceLock.js';
|
|
6
|
+
import type { IRepositoryLocator, RepositoryPaths } from '../../contracts/IRepositoryLocator.js';
|
|
7
|
+
import type { ITextSink } from '../../contracts/ITextSink.js';
|
|
8
|
+
export interface ResumeSources {
|
|
9
|
+
readonly lock: IInstanceLock;
|
|
10
|
+
readonly halt: IHaltStore;
|
|
11
|
+
readonly git: IGitReader;
|
|
12
|
+
/** Unguarded: resume owns the halt and moves the verified ref before clearing it. */
|
|
13
|
+
readonly writer: IGitWriter;
|
|
14
|
+
}
|
|
15
|
+
export interface ResumeCommandInput {
|
|
16
|
+
readonly startDir: string;
|
|
17
|
+
/** Adopt the rewritten upstream: move the verified ref to the remote head. */
|
|
18
|
+
readonly acceptUpstream: boolean;
|
|
19
|
+
}
|
|
20
|
+
export interface ResumeCommandDeps {
|
|
21
|
+
readonly locator: IRepositoryLocator;
|
|
22
|
+
readonly openSources: (paths: RepositoryPaths) => ResumeSources;
|
|
23
|
+
readonly stdout: ITextSink;
|
|
24
|
+
}
|
|
25
|
+
export type ResumeOutcome = 'cleared' | 'nothing-to-resume' | 'refused-instance-running' | 'refused-operation-in-progress' | 'refused-unmerged' | 'refused-upstream-not-reconciled' | 'refused-history-still-local' | 'refused-upstream-unreachable';
|
|
26
|
+
export interface ResumeCommandResult {
|
|
27
|
+
readonly exitCode: ExitCode;
|
|
28
|
+
readonly outcome: ResumeOutcome;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Controller:
|
|
32
|
+
* 1. locator.locate(startDir); not found or bare: exit 5.
|
|
33
|
+
* 2. lock.inspect() live: refused-instance-running, exit 4.
|
|
34
|
+
* 3. halt.read() null: nothing-to-resume, exit 0.
|
|
35
|
+
* 4. An operation in progress or unmerged paths: refused, exit 3, marker kept.
|
|
36
|
+
* 5. UPSTREAM_REWRITTEN only, judged against the remote the way the next cycle
|
|
37
|
+
* judges it: remoteHead of the branch's upstream, fetched into the incoming
|
|
38
|
+
* ref (no upstream, unreachable, absent or a failed fetch:
|
|
39
|
+
* refused-upstream-unreachable, exit 3). Clear when the verified ref is an
|
|
40
|
+
* ancestor of the remote sha; otherwise require acceptUpstream, refuse while
|
|
41
|
+
* the verified ref is still an ancestor of HEAD (the local branch carries
|
|
42
|
+
* the rewritten history), then move the verified ref to the remote sha.
|
|
43
|
+
* The tracking ref is never the reference: enmetu does not move it to a
|
|
44
|
+
* rewritten upstream, so after enmetu's own halt it still equals the
|
|
45
|
+
* verified ref and would pass any ancestry check.
|
|
46
|
+
* 6. halt.clear(), print what was cleared, exit 0.
|
|
47
|
+
*/
|
|
48
|
+
export declare function resumeCommand(input: ResumeCommandInput, deps: ResumeCommandDeps): Promise<ResumeCommandResult>;
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { EXIT } from '../../contracts/exitCode.js';
|
|
2
|
+
import { incomingRefFor, verifiedRefFor } from '../../core/git/enmetuRefs.js';
|
|
3
|
+
/**
|
|
4
|
+
* Controller:
|
|
5
|
+
* 1. locator.locate(startDir); not found or bare: exit 5.
|
|
6
|
+
* 2. lock.inspect() live: refused-instance-running, exit 4.
|
|
7
|
+
* 3. halt.read() null: nothing-to-resume, exit 0.
|
|
8
|
+
* 4. An operation in progress or unmerged paths: refused, exit 3, marker kept.
|
|
9
|
+
* 5. UPSTREAM_REWRITTEN only, judged against the remote the way the next cycle
|
|
10
|
+
* judges it: remoteHead of the branch's upstream, fetched into the incoming
|
|
11
|
+
* ref (no upstream, unreachable, absent or a failed fetch:
|
|
12
|
+
* refused-upstream-unreachable, exit 3). Clear when the verified ref is an
|
|
13
|
+
* ancestor of the remote sha; otherwise require acceptUpstream, refuse while
|
|
14
|
+
* the verified ref is still an ancestor of HEAD (the local branch carries
|
|
15
|
+
* the rewritten history), then move the verified ref to the remote sha.
|
|
16
|
+
* The tracking ref is never the reference: enmetu does not move it to a
|
|
17
|
+
* rewritten upstream, so after enmetu's own halt it still equals the
|
|
18
|
+
* verified ref and would pass any ancestry check.
|
|
19
|
+
* 6. halt.clear(), print what was cleared, exit 0.
|
|
20
|
+
*/
|
|
21
|
+
export async function resumeCommand(input, deps) {
|
|
22
|
+
// --- Step 1: the repository root through git ---
|
|
23
|
+
const location = await deps.locator.locate(input.startDir);
|
|
24
|
+
if (location.kind !== 'found') {
|
|
25
|
+
const reason = location.kind === 'bare'
|
|
26
|
+
? 'is a bare repository'
|
|
27
|
+
: 'is not inside a git work tree';
|
|
28
|
+
say(deps, `${input.startDir} ${reason}; there is no halt to resume`);
|
|
29
|
+
return { exitCode: EXIT.NOT_ELIGIBLE, outcome: 'nothing-to-resume' };
|
|
30
|
+
}
|
|
31
|
+
const sources = deps.openSources(location.paths);
|
|
32
|
+
// --- Step 2: a running daemon owns the repository ---
|
|
33
|
+
const holder = await sources.lock.inspect();
|
|
34
|
+
if (holder !== null) {
|
|
35
|
+
say(deps, `resume refused: enmetu runs on this worktree (pid ${holder.pid} on ${holder.hostname} since ${holder.startedAt}); stop it first`);
|
|
36
|
+
return { exitCode: EXIT.LOCKED, outcome: 'refused-instance-running' };
|
|
37
|
+
}
|
|
38
|
+
// --- Step 3: the halt marker ---
|
|
39
|
+
const record = await sources.halt.read();
|
|
40
|
+
if (record === null) {
|
|
41
|
+
say(deps, 'not halted; nothing to resume');
|
|
42
|
+
return { exitCode: EXIT.OK, outcome: 'nothing-to-resume' };
|
|
43
|
+
}
|
|
44
|
+
// --- Step 4: no operation in progress, nothing unmerged ---
|
|
45
|
+
const state = await sources.git.repositoryState();
|
|
46
|
+
if (state.operation !== null) {
|
|
47
|
+
return refuse(deps, 'refused-operation-in-progress', `a ${state.operation} is in progress; finish or abort it first`);
|
|
48
|
+
}
|
|
49
|
+
if (await sources.git.hasUnmergedPaths()) {
|
|
50
|
+
return refuse(deps, 'refused-unmerged', 'unmerged paths exist; resolve them first');
|
|
51
|
+
}
|
|
52
|
+
// --- Step 5: a rewritten upstream must be reconciled (Decision 43) ---
|
|
53
|
+
if (record.code === 'UPSTREAM_REWRITTEN') {
|
|
54
|
+
const judgement = await judgeRewrittenUpstream(state.branchRef, input.acceptUpstream, sources, deps);
|
|
55
|
+
if (judgement.kind === 'refused') {
|
|
56
|
+
return judgement.result;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
// --- Step 6: clear ---
|
|
60
|
+
await sources.halt.clear();
|
|
61
|
+
say(deps, `halt cleared (${describeRecord(record)})`);
|
|
62
|
+
return { exitCode: EXIT.OK, outcome: 'cleared' };
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* The comparison the next cycle makes (1.10 steps 6 and 7): the remote head,
|
|
66
|
+
* against the verified ref, or the tracking ref the cycle would seed it from
|
|
67
|
+
* when it is missing. With no reference point at all the next cycle has
|
|
68
|
+
* nothing to compare, so it cannot halt again.
|
|
69
|
+
*/
|
|
70
|
+
async function judgeRewrittenUpstream(branchRef, acceptUpstream, sources, deps) {
|
|
71
|
+
const upstream = branchRef === null ? null : await sources.git.upstream(branchRef);
|
|
72
|
+
if (branchRef === null || upstream === null) {
|
|
73
|
+
return unreachable(deps, 'the checked-out branch has no upstream to compare with');
|
|
74
|
+
}
|
|
75
|
+
const remoteHead = await sources.git.remoteHead(upstream.remote, upstream.remoteRef);
|
|
76
|
+
if (remoteHead.kind === 'unreachable') {
|
|
77
|
+
return unreachable(deps, `${upstream.remote} cannot be reached (${remoteHead.detail})`);
|
|
78
|
+
}
|
|
79
|
+
if (remoteHead.kind === 'absent') {
|
|
80
|
+
return unreachable(deps, `${upstream.remote} has no ${upstream.remoteRef}; enmetu never recreates a deleted remote branch`);
|
|
81
|
+
}
|
|
82
|
+
const remoteSha = remoteHead.sha;
|
|
83
|
+
// The objects must be local for the ancestry checks; the incoming ref is
|
|
84
|
+
// enmetu's own, so no person's ref moves.
|
|
85
|
+
const fetched = await sources.writer.fetch(upstream.remote, upstream.remoteRef, incomingRefFor(branchRef));
|
|
86
|
+
if (fetched.kind === 'failed') {
|
|
87
|
+
return unreachable(deps, `fetching ${upstream.remote} ${upstream.remoteRef} failed (${fetched.detail})`);
|
|
88
|
+
}
|
|
89
|
+
const verifiedRef = verifiedRefFor(branchRef);
|
|
90
|
+
const verifiedSha = await sources.git.resolveCommit(verifiedRef);
|
|
91
|
+
const referenceSha = verifiedSha ?? (await sources.git.resolveCommit(upstream.trackingRef));
|
|
92
|
+
if (referenceSha === null ||
|
|
93
|
+
referenceSha === remoteSha ||
|
|
94
|
+
(await sources.git.isAncestor(referenceSha, remoteSha))) {
|
|
95
|
+
return { kind: 'clear' };
|
|
96
|
+
}
|
|
97
|
+
if (!acceptUpstream) {
|
|
98
|
+
return refused(deps, 'refused-upstream-not-reconciled', `${upstream.remote} ${upstream.remoteRef} at ${remoteSha} still does not descend from ${referenceSha}; reconcile the branch with the rewritten upstream, then run: enmetu resume --accept-upstream`);
|
|
99
|
+
}
|
|
100
|
+
const head = await sources.git.headCommit();
|
|
101
|
+
if (head !== null && (await sources.git.isAncestor(referenceSha, head))) {
|
|
102
|
+
// The next rebase would replay the purged commits onto the new upstream.
|
|
103
|
+
return refused(deps, 'refused-history-still-local', `${branchRef} still contains ${referenceSha}, the history the force-push removed; rebase or reset the branch onto ${upstream.remote} ${upstream.remoteRef} first`);
|
|
104
|
+
}
|
|
105
|
+
await sources.writer.updateRef(verifiedRef, remoteSha, verifiedSha);
|
|
106
|
+
return { kind: 'clear' };
|
|
107
|
+
}
|
|
108
|
+
function unreachable(deps, reason) {
|
|
109
|
+
return refused(deps, 'refused-upstream-unreachable', reason);
|
|
110
|
+
}
|
|
111
|
+
function refused(deps, outcome, reason) {
|
|
112
|
+
return { kind: 'refused', result: refuse(deps, outcome, reason) };
|
|
113
|
+
}
|
|
114
|
+
/** Every refusal after the lock check keeps the marker and exits 3. */
|
|
115
|
+
function refuse(deps, outcome, reason) {
|
|
116
|
+
say(deps, `resume refused: ${reason}; the halt stays`);
|
|
117
|
+
return { exitCode: EXIT.HALTED, outcome };
|
|
118
|
+
}
|
|
119
|
+
function describeRecord(record) {
|
|
120
|
+
return `${record.code} at ${record.at}: ${record.message}`;
|
|
121
|
+
}
|
|
122
|
+
function say(deps, text) {
|
|
123
|
+
deps.stdout.write(`enmetu: ${text}\n`);
|
|
124
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { EnmetuConfig } from '../../contracts/config.js';
|
|
2
|
+
import { type ExitCode } from '../../contracts/exitCode.js';
|
|
3
|
+
import type { IDaemon } from '../../contracts/IDaemon.js';
|
|
4
|
+
import type { IFileSystem } from '../../contracts/IFileSystem.js';
|
|
5
|
+
import type { ILogger } from '../../contracts/ILogger.js';
|
|
6
|
+
import type { IRepositoryLocator, RepositoryPaths } from '../../contracts/IRepositoryLocator.js';
|
|
7
|
+
export interface RunCommandInput {
|
|
8
|
+
/** --repo, else the working directory. */
|
|
9
|
+
readonly startDir: string;
|
|
10
|
+
}
|
|
11
|
+
export interface RunCommandDeps {
|
|
12
|
+
readonly locator: IRepositoryLocator;
|
|
13
|
+
readonly fs: IFileSystem;
|
|
14
|
+
readonly logger: ILogger;
|
|
15
|
+
/** Composition-root closure; builds the daemon for the resolved repository. */
|
|
16
|
+
readonly createDaemon: (paths: RepositoryPaths, config: EnmetuConfig) => IDaemon;
|
|
17
|
+
}
|
|
18
|
+
export interface RunCommandResult {
|
|
19
|
+
readonly exitCode: ExitCode;
|
|
20
|
+
}
|
|
21
|
+
/** Minimum git version (Decision 16): --no-update-refs appeared in 2.38. */
|
|
22
|
+
export declare const MIN_GIT_VERSION: {
|
|
23
|
+
major: number;
|
|
24
|
+
minor: number;
|
|
25
|
+
};
|
|
26
|
+
/**
|
|
27
|
+
* Controller:
|
|
28
|
+
* 1. locator.locate(startDir): not a repository, bare, or PATH_UNSUPPORTED log NOT_ELIGIBLE, exit 5.
|
|
29
|
+
* 2. Git older than 2.38: NOT_ELIGIBLE (GIT_TOO_OLD), exit 5.
|
|
30
|
+
* 3. parseConfig of <root>/enmetu.config.json: ConfigError logs CONFIG_INVALID, exit 2;
|
|
31
|
+
* each unknown key logs CONFIG_KEY_IGNORED once and is ignored (design 14.17).
|
|
32
|
+
* 4. createDaemon(paths, config).run(), its exit code returned.
|
|
33
|
+
*/
|
|
34
|
+
export declare function runCommand(input: RunCommandInput, deps: RunCommandDeps): Promise<RunCommandResult>;
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { join } from 'node:path';
|
|
2
|
+
import { ConfigError, PreflightError, } from '../../contracts/errors.js';
|
|
3
|
+
import { EXIT } from '../../contracts/exitCode.js';
|
|
4
|
+
import { CONFIG_FILE_NAME, parseConfig } from '../../core/config/parseConfig.js';
|
|
5
|
+
/** Minimum git version (Decision 16): --no-update-refs appeared in 2.38. */
|
|
6
|
+
export const MIN_GIT_VERSION = { major: 2, minor: 38 };
|
|
7
|
+
/**
|
|
8
|
+
* Controller:
|
|
9
|
+
* 1. locator.locate(startDir): not a repository, bare, or PATH_UNSUPPORTED log NOT_ELIGIBLE, exit 5.
|
|
10
|
+
* 2. Git older than 2.38: NOT_ELIGIBLE (GIT_TOO_OLD), exit 5.
|
|
11
|
+
* 3. parseConfig of <root>/enmetu.config.json: ConfigError logs CONFIG_INVALID, exit 2;
|
|
12
|
+
* each unknown key logs CONFIG_KEY_IGNORED once and is ignored (design 14.17).
|
|
13
|
+
* 4. createDaemon(paths, config).run(), its exit code returned.
|
|
14
|
+
*/
|
|
15
|
+
export async function runCommand(input, deps) {
|
|
16
|
+
// --- Step 1: the repository root through git, not the start directory (D9) ---
|
|
17
|
+
let location;
|
|
18
|
+
try {
|
|
19
|
+
location = await deps.locator.locate(input.startDir);
|
|
20
|
+
}
|
|
21
|
+
catch (error) {
|
|
22
|
+
if (error instanceof PreflightError) {
|
|
23
|
+
return notEligible(deps, error.code, error.message);
|
|
24
|
+
}
|
|
25
|
+
throw error;
|
|
26
|
+
}
|
|
27
|
+
if (location.kind === 'not-a-repository') {
|
|
28
|
+
return notEligible(deps, 'NOT_A_REPOSITORY', `${input.startDir} is not inside a git work tree`);
|
|
29
|
+
}
|
|
30
|
+
if (location.kind === 'bare') {
|
|
31
|
+
return notEligible(deps, 'BARE_REPOSITORY', `${input.startDir} is a bare repository; enmetu needs a work tree`);
|
|
32
|
+
}
|
|
33
|
+
const { paths, version } = location;
|
|
34
|
+
// --- Step 2: the git version gate ---
|
|
35
|
+
if (olderThanMinimum(version)) {
|
|
36
|
+
return notEligible(deps, 'GIT_TOO_OLD', `git ${version.major}.${version.minor}.${version.patch} is older than ${MIN_GIT_VERSION.major}.${MIN_GIT_VERSION.minor}`);
|
|
37
|
+
}
|
|
38
|
+
// --- Step 3: the config at the resolved root, defaults when absent ---
|
|
39
|
+
const configPath = join(paths.root, CONFIG_FILE_NAME);
|
|
40
|
+
let parsed;
|
|
41
|
+
try {
|
|
42
|
+
parsed = parseConfig(await deps.fs.readText(configPath));
|
|
43
|
+
}
|
|
44
|
+
catch (error) {
|
|
45
|
+
if (!(error instanceof ConfigError)) {
|
|
46
|
+
throw error;
|
|
47
|
+
}
|
|
48
|
+
deps.logger.log({
|
|
49
|
+
level: 'ERROR',
|
|
50
|
+
code: 'CONFIG_INVALID',
|
|
51
|
+
message: `${configPath}: ${error.code}: ${error.message}`,
|
|
52
|
+
});
|
|
53
|
+
return { exitCode: EXIT.USAGE };
|
|
54
|
+
}
|
|
55
|
+
// A key only a newer enmetu knows must not stop this one (design 14.17).
|
|
56
|
+
for (const key of parsed.unknownKeys) {
|
|
57
|
+
deps.logger.log({
|
|
58
|
+
level: 'WARN',
|
|
59
|
+
code: 'CONFIG_KEY_IGNORED',
|
|
60
|
+
message: `${CONFIG_FILE_NAME}: unknown setting "${key}", ignored`,
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
const { config } = parsed;
|
|
64
|
+
// --- Step 4: the daemon for this repository ---
|
|
65
|
+
return { exitCode: await deps.createDaemon(paths, config).run() };
|
|
66
|
+
}
|
|
67
|
+
function olderThanMinimum(version) {
|
|
68
|
+
return (version.major < MIN_GIT_VERSION.major ||
|
|
69
|
+
(version.major === MIN_GIT_VERSION.major &&
|
|
70
|
+
version.minor < MIN_GIT_VERSION.minor));
|
|
71
|
+
}
|
|
72
|
+
function notEligible(deps, code, message) {
|
|
73
|
+
deps.logger.log({
|
|
74
|
+
level: 'ERROR',
|
|
75
|
+
code: 'NOT_ELIGIBLE',
|
|
76
|
+
message: `${code}: ${message}`,
|
|
77
|
+
});
|
|
78
|
+
return { exitCode: EXIT.NOT_ELIGIBLE };
|
|
79
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { type ExitCode } from '../../contracts/exitCode.js';
|
|
2
|
+
import type { IGitReader } from '../../contracts/IGitReader.js';
|
|
3
|
+
import type { IHaltStore } from '../../contracts/IHaltStore.js';
|
|
4
|
+
import type { ILockInspector } from '../../contracts/IInstanceLock.js';
|
|
5
|
+
import type { IOperationJournal } from '../../contracts/IOperationJournal.js';
|
|
6
|
+
import type { IPathQuarantine } from '../../contracts/IPathQuarantine.js';
|
|
7
|
+
import type { IRepositoryLocator, RepositoryPaths } from '../../contracts/IRepositoryLocator.js';
|
|
8
|
+
import type { IStateStore } from '../../contracts/IStateStore.js';
|
|
9
|
+
import type { ITextSink } from '../../contracts/ITextSink.js';
|
|
10
|
+
import { type GuideLine } from '../../core/status/conflictGuide.js';
|
|
11
|
+
/** Read-only views of one repository's state; status never writes and never contacts the remote. */
|
|
12
|
+
export interface StatusSources {
|
|
13
|
+
readonly lock: ILockInspector;
|
|
14
|
+
readonly halt: IHaltStore;
|
|
15
|
+
readonly journal: IOperationJournal;
|
|
16
|
+
readonly quarantine: IPathQuarantine;
|
|
17
|
+
readonly git: IGitReader;
|
|
18
|
+
/** state.json, read for the headline, the state line and the conflict guide (design 12.2, 12.4). */
|
|
19
|
+
readonly state: IStateStore;
|
|
20
|
+
}
|
|
21
|
+
export interface StatusCommandInput {
|
|
22
|
+
readonly startDir: string;
|
|
23
|
+
}
|
|
24
|
+
export interface StatusCommandDeps {
|
|
25
|
+
readonly locator: IRepositoryLocator;
|
|
26
|
+
readonly openSources: (paths: RepositoryPaths) => StatusSources;
|
|
27
|
+
readonly stdout: ITextSink;
|
|
28
|
+
}
|
|
29
|
+
/** One value per printed line, in print order; the headline is printed without a label. */
|
|
30
|
+
export interface StatusReport {
|
|
31
|
+
readonly headline: string;
|
|
32
|
+
readonly repository: string;
|
|
33
|
+
readonly instance: string;
|
|
34
|
+
readonly state: string;
|
|
35
|
+
readonly halt: string;
|
|
36
|
+
readonly recovery: string;
|
|
37
|
+
readonly branch: string;
|
|
38
|
+
readonly divergence: string;
|
|
39
|
+
readonly uncommitted: string;
|
|
40
|
+
/** One line per recorded orphaned commit still on no local branch (design 14.15); empty when none. */
|
|
41
|
+
readonly orphaned: readonly GuideLine[];
|
|
42
|
+
/** The conflict guide's lines after uncommitted (design 12.3); empty when no conflict is held at HEAD. */
|
|
43
|
+
readonly conflictGuide: readonly GuideLine[];
|
|
44
|
+
}
|
|
45
|
+
export interface StatusCommandResult {
|
|
46
|
+
readonly exitCode: ExitCode;
|
|
47
|
+
/** null when the start directory is not in an eligible repository. */
|
|
48
|
+
readonly report: StatusReport | null;
|
|
49
|
+
}
|
|
50
|
+
/** The uncommitted line names this many paths, then "and <n> more" (design 12.2). */
|
|
51
|
+
export declare const MAX_UNCOMMITTED_PATHS = 20;
|
|
52
|
+
/**
|
|
53
|
+
* Controller:
|
|
54
|
+
* 1. locator.locate(startDir); not found or bare: exit 5.
|
|
55
|
+
* 2. Gather the facts, all local (no network, no write): state.read(),
|
|
56
|
+
* lock.inspect(), state.read() again, liveness judged with the second
|
|
57
|
+
* record and the holder (Decision 86); halt.read(), journal.read(),
|
|
58
|
+
* quarantine.hasPending(); branch and upstream, divergence against
|
|
59
|
+
* trackingSha ?? verifiedSha (Decision 66); uncommitted paths from
|
|
60
|
+
* git.status(), which runs with GIT_OPTIONAL_LOCKS=0, so no index write
|
|
61
|
+
* (Decision 85). While a conflict is held at HEAD, the conflict guide
|
|
62
|
+
* (design 12.3) from merge-base and two name-only diffs, which write no
|
|
63
|
+
* object (Decision 67). Each recorded orphaned commit asked of git
|
|
64
|
+
* (design 14.15).
|
|
65
|
+
* 3. Print the headline, then one labelled line per fact (design 1.18, 12.2:
|
|
66
|
+
* state after instance, uncommitted after divergence, then the pending
|
|
67
|
+
* orphaned commits, then the guide);
|
|
68
|
+
* halted exits 3, else 0. The exit codes answer scripts and are unchanged
|
|
69
|
+
* (Decision 66).
|
|
70
|
+
*/
|
|
71
|
+
export declare function statusCommand(input: StatusCommandInput, deps: StatusCommandDeps): Promise<StatusCommandResult>;
|