@fluado/enmetu 0.2.0 → 0.4.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/README.md +60 -8
- package/dist/adapters/git/gitFacade.d.ts +8 -0
- package/dist/adapters/git/gitFacade.js +34 -1
- package/dist/adapters/git/gitProcessAdapter.js +1 -48
- package/dist/adapters/host/hostMessageWriterAdapter.d.ts +23 -0
- package/dist/adapters/host/hostMessageWriterAdapter.js +76 -0
- package/dist/adapters/process/processGroup.d.ts +18 -0
- package/dist/adapters/process/processGroup.js +52 -0
- package/dist/adapters/process/shellMessageCommandAdapter.d.ts +24 -0
- package/dist/adapters/process/shellMessageCommandAdapter.js +102 -0
- package/dist/adapters/watch/watcherFor.d.ts +22 -0
- package/dist/adapters/watch/watcherFor.js +29 -0
- package/dist/cli/commands/hookCommand.d.ts +2 -1
- package/dist/cli/commands/hookCommand.js +14 -8
- package/dist/cli/commands/runCommand.d.ts +10 -3
- package/dist/cli/commands/runCommand.js +45 -9
- package/dist/cli.js +2 -0
- package/dist/commit/commitLocalChangesController.d.ts +37 -6
- package/dist/commit/commitLocalChangesController.js +134 -18
- package/dist/commit/stageGuardedChanges.d.ts +3 -1
- package/dist/commit/stageGuardedChanges.js +5 -4
- package/dist/contracts/ICommitMessageCommand.d.ts +32 -0
- package/dist/contracts/ICommitMessageCommand.js +1 -0
- package/dist/contracts/IGitConfigReader.d.ts +4 -1
- package/dist/contracts/IGitReader.d.ts +5 -0
- package/dist/contracts/IHostMessageWriter.d.ts +28 -0
- package/dist/contracts/IHostMessageWriter.js +1 -0
- package/dist/contracts/config.d.ts +34 -1
- package/dist/contracts/errors.d.ts +5 -1
- package/dist/contracts/errors.js +5 -1
- package/dist/contracts/library.d.ts +30 -0
- package/dist/contracts/logCodes.d.ts +1 -1
- package/dist/contracts/runtime.d.ts +4 -0
- package/dist/contracts/syncTypes.d.ts +23 -4
- package/dist/contracts/syncTypes.js +2 -0
- package/dist/core/commit/commitMessage.d.ts +27 -0
- package/dist/core/commit/commitMessage.js +61 -0
- package/dist/core/config/parseConfig.d.ts +7 -4
- package/dist/core/config/parseConfig.js +172 -26
- package/dist/core/git/porcelain.d.ts +7 -0
- package/dist/core/git/porcelain.js +24 -0
- package/dist/core/quarantine/restoreWarnings.d.ts +6 -3
- package/dist/core/quarantine/restoreWarnings.js +12 -3
- package/dist/core/state/stateGuards.js +1 -0
- package/dist/core/status/statusHeadline.js +3 -0
- package/dist/enmetuInstances.d.ts +7 -4
- package/dist/enmetuInstances.js +52 -13
- package/dist/index.d.ts +1 -1
- package/dist/index.js +2 -0
- package/dist/library/enmetuLibrary.d.ts +2 -1
- package/dist/library/enmetuLibrary.js +5 -2
- package/dist/quarantine/pathQuarantine.d.ts +10 -0
- package/dist/quarantine/pathQuarantine.js +44 -2
- package/dist/sync/integrateUpstreamController.d.ts +16 -2
- package/dist/sync/integrateUpstreamController.js +21 -1
- package/dist/sync/syncCycleController.d.ts +6 -1
- package/dist/sync/syncCycleController.js +24 -2
- package/dist/sync/syncCycleRunner.js +1 -0
- package/package.json +1 -1
|
@@ -69,6 +69,9 @@ function runningHeadline(record, uncommitted, unpushed) {
|
|
|
69
69
|
if (record.reason === 'COMMIT_HOOK_REJECTED') {
|
|
70
70
|
return `waiting: a commit hook rejected the last commit since ${since}, ${work}`;
|
|
71
71
|
}
|
|
72
|
+
if (record.reason === 'TREE_CHANGING') {
|
|
73
|
+
return `waiting: a save landed during the last cycle since ${since}; the pull waits for a quiet cycle, ${work}`;
|
|
74
|
+
}
|
|
72
75
|
return `waiting:${reasonText(record)} since ${since}, ${work}`;
|
|
73
76
|
case 'paused':
|
|
74
77
|
return `paused:${reasonText(record)} since ${since}, ${work}`;
|
|
@@ -7,6 +7,7 @@ import type { IDaemon } from './contracts/IDaemon.js';
|
|
|
7
7
|
import type { IHookInstaller } from './contracts/IHookInstaller.js';
|
|
8
8
|
import type { IProcessHost } from './contracts/IProcessHost.js';
|
|
9
9
|
import type { IRepositoryLocator, RepositoryPaths } from './contracts/IRepositoryLocator.js';
|
|
10
|
+
import type { CommitMessageWriter } from './contracts/library.js';
|
|
10
11
|
import type { Runtime } from './contracts/runtime.js';
|
|
11
12
|
import type { ReadStateDeps } from './library/readStateController.js';
|
|
12
13
|
import { type RecoveryDeps } from './recovery/recoveryController.js';
|
|
@@ -25,9 +26,10 @@ export declare function createRecoveryDeps(paths: RepositoryPaths, config: Enmet
|
|
|
25
26
|
* their controllers. Recovery, preflight, hook installer and cycle share one
|
|
26
27
|
* set of repository parts, so one halt store guards every writer. processHost
|
|
27
28
|
* defaults to the operating system's signals; the library passes
|
|
28
|
-
* RequestedStopProcessHost so a host program's process is left alone
|
|
29
|
+
* RequestedStopProcessHost so a host program's process is left alone, and the
|
|
30
|
+
* host's commitMessage writer when it has one (design 14.19).
|
|
29
31
|
*/
|
|
30
|
-
export declare function createDaemon(paths: RepositoryPaths, config: EnmetuConfig, runtime: Runtime, processHost?: IProcessHost | undefined): IDaemon;
|
|
32
|
+
export declare function createDaemon(paths: RepositoryPaths, config: EnmetuConfig, runtime: Runtime, processHost?: IProcessHost | undefined, commitMessage?: CommitMessageWriter | undefined): IDaemon;
|
|
31
33
|
/**
|
|
32
34
|
* The daemon's hook installer for one repository (design 13.1): it records
|
|
33
35
|
* Runtime.execPath, resolved through the PATH enmetu was started with when it
|
|
@@ -58,8 +60,9 @@ export interface EmbeddedRun {
|
|
|
58
60
|
/**
|
|
59
61
|
* runCommand's deps with one process host shared by the daemon and its lock:
|
|
60
62
|
* the lock still records this process's pid and hostname, while stops come
|
|
61
|
-
* from requestStop() instead of the host program's signals.
|
|
63
|
+
* from requestStop() instead of the host program's signals. commitMessage is
|
|
64
|
+
* the host's writer, when it passed one (design 14.19).
|
|
62
65
|
*/
|
|
63
|
-
export declare function createEmbeddedRunDeps(runtime: Runtime): EmbeddedRun;
|
|
66
|
+
export declare function createEmbeddedRunDeps(runtime: Runtime, commitMessage?: CommitMessageWriter | undefined): EmbeddedRun;
|
|
64
67
|
/** The library's state read: the state file and the instance lock, nothing else (design 12.5). */
|
|
65
68
|
export declare function createReadStateDeps(runtime: Runtime): ReadStateDeps;
|
package/dist/enmetuInstances.js
CHANGED
|
@@ -5,10 +5,12 @@ import { NodeFileSystemAdapter } from './adapters/fs/nodeFileSystemAdapter.js';
|
|
|
5
5
|
import { GitFacade } from './adapters/git/gitFacade.js';
|
|
6
6
|
import { GitLocatorFacade } from './adapters/git/gitLocatorFacade.js';
|
|
7
7
|
import { GitProcessAdapter } from './adapters/git/gitProcessAdapter.js';
|
|
8
|
+
import { HostMessageWriterAdapter } from './adapters/host/hostMessageWriterAdapter.js';
|
|
8
9
|
import { NullLogger } from './adapters/log/nullLogger.js';
|
|
9
10
|
import { StderrLoggerAdapter } from './adapters/log/stderrLoggerAdapter.js';
|
|
10
11
|
import { NodeProcessAdapter } from './adapters/process/nodeProcessAdapter.js';
|
|
11
|
-
import {
|
|
12
|
+
import { ShellMessageCommandAdapter } from './adapters/process/shellMessageCommandAdapter.js';
|
|
13
|
+
import { watcherFor } from './adapters/watch/watcherFor.js';
|
|
12
14
|
import { commitLocalChangesController, } from './commit/commitLocalChangesController.js';
|
|
13
15
|
import { GitIndexLock } from './commit/gitIndexLock.js';
|
|
14
16
|
import { DEFAULT_CONFIG } from './core/config/parseConfig.js';
|
|
@@ -73,9 +75,10 @@ export function createRecoveryDeps(paths, config, runtime) {
|
|
|
73
75
|
* their controllers. Recovery, preflight, hook installer and cycle share one
|
|
74
76
|
* set of repository parts, so one halt store guards every writer. processHost
|
|
75
77
|
* defaults to the operating system's signals; the library passes
|
|
76
|
-
* RequestedStopProcessHost so a host program's process is left alone
|
|
78
|
+
* RequestedStopProcessHost so a host program's process is left alone, and the
|
|
79
|
+
* host's commitMessage writer when it has one (design 14.19).
|
|
77
80
|
*/
|
|
78
|
-
export function createDaemon(paths, config, runtime, processHost) {
|
|
81
|
+
export function createDaemon(paths, config, runtime, processHost, commitMessage) {
|
|
79
82
|
const parts = guardedRepositoryParts(paths, config.gitTimeoutMs, runtime);
|
|
80
83
|
const host = processHost ?? new NodeProcessAdapter();
|
|
81
84
|
// One recorder for the daemon's lifecycle events, the Decorator's cycle
|
|
@@ -93,7 +96,7 @@ export function createDaemon(paths, config, runtime, processHost) {
|
|
|
93
96
|
// Aborted by the first stop request, so a running repository hook ends at
|
|
94
97
|
// once instead of holding the stop for up to hookTimeoutMs (Decision 92).
|
|
95
98
|
const hookStop = new AbortController();
|
|
96
|
-
const cycleDeps = syncCycleDepsOf(parts, commitOf(parts, paths, config, runtime, hookStop.signal), recorder);
|
|
99
|
+
const cycleDeps = syncCycleDepsOf(parts, commitOf(parts, paths, config, runtime, hookStop.signal, commitMessage), recorder);
|
|
97
100
|
const cycle = new StateRecordingSyncCycle({ run: (input) => syncCycleController(input, cycleDeps) }, state, parts.git);
|
|
98
101
|
const hookInstaller = hookInstallerOf(parts, paths, config, runtime);
|
|
99
102
|
const scheduler = new HookAbortingSyncScheduler(new SyncScheduler(new SyncCycleRunner(cycle, parts.logger), parts.clock, {
|
|
@@ -101,7 +104,7 @@ export function createDaemon(paths, config, runtime, processHost) {
|
|
|
101
104
|
maxDelayMs: config.maxDelayMs,
|
|
102
105
|
pollMs: config.pollMs,
|
|
103
106
|
}), hookStop);
|
|
104
|
-
return new Daemon({ root: paths.root, quietMs: config.quietMs, pollMs: config.pollMs }, lockFor(paths, parts.fs, host, parts.clock), parts.halt, recovery, preflight, hookInstaller,
|
|
107
|
+
return new Daemon({ root: paths.root, quietMs: config.quietMs, pollMs: config.pollMs }, lockFor(paths, parts.fs, host, parts.clock), parts.halt, recovery, preflight, hookInstaller, watcherFor(paths.root, runtime), scheduler, host, parts.logger, state, parts.clock);
|
|
105
108
|
}
|
|
106
109
|
/**
|
|
107
110
|
* The daemon's hook installer for one repository (design 13.1): it records
|
|
@@ -118,13 +121,19 @@ function hookInstallerOf(parts, paths, config, runtime) {
|
|
|
118
121
|
: new NullHookInstaller();
|
|
119
122
|
}
|
|
120
123
|
export function createRunDeps(runtime) {
|
|
124
|
+
const fs = new NodeFileSystemAdapter();
|
|
121
125
|
return {
|
|
122
126
|
locator: createLocator(runtime),
|
|
123
|
-
fs
|
|
127
|
+
fs,
|
|
128
|
+
readGitConfig: gitConfigReaderFor(runtime, fs),
|
|
124
129
|
logger: new StderrLoggerAdapter(runtime.stderr, new SystemClockAdapter()),
|
|
125
130
|
createDaemon: (paths, config) => createDaemon(paths, config, runtime),
|
|
126
131
|
};
|
|
127
132
|
}
|
|
133
|
+
/** The run command's git config read, before the config and its git timeout are known (design 14.18). */
|
|
134
|
+
function gitConfigReaderFor(runtime, fs) {
|
|
135
|
+
return (paths) => facadeFor(paths, UNCONFIGURED_GIT_TIMEOUT_MS, runtime, fs).enmetuEntries();
|
|
136
|
+
}
|
|
128
137
|
/**
|
|
129
138
|
* Status reads only; its quarantine still sits on the guarded writer, so even
|
|
130
139
|
* a mistaken write from status would honour the halt.
|
|
@@ -251,15 +260,27 @@ function lockFor(paths, fs, processHost, clock) {
|
|
|
251
260
|
}
|
|
252
261
|
/** The commit index of design 12.7, under the state directory, never committed. */
|
|
253
262
|
const COMMIT_INDEX_FILE = 'commit-index';
|
|
263
|
+
/** commitMessageCommand's fixed time limit, then commitSubject (design 14.18, Decision 131). */
|
|
264
|
+
const MESSAGE_COMMAND_TIMEOUT_MS = 5000;
|
|
265
|
+
/** The most of a message command's stdout enmetu reads; a message is far shorter. */
|
|
266
|
+
const MESSAGE_COMMAND_OUTPUT_CAP_BYTES = 65536;
|
|
267
|
+
/**
|
|
268
|
+
* A host writer's time limit: the command's own limit, which the writer may
|
|
269
|
+
* wait for through runCommand, plus room for its own git reads (design 14.19,
|
|
270
|
+
* Decision 135).
|
|
271
|
+
*/
|
|
272
|
+
const HOST_MESSAGE_TIMEOUT_MS = 10000;
|
|
254
273
|
/**
|
|
255
274
|
* The commit step bound into a closure (design 12.7): a second facade whose
|
|
256
275
|
* runner sets GIT_INDEX_FILE to the commit index, its writer behind the same
|
|
257
276
|
* halt Decorator, a real-index facade for post-commit and post-index-change,
|
|
258
277
|
* both facades with the hook timeout and the daemon's hook stop signal
|
|
259
|
-
* (undefined outside the daemon), the index lock
|
|
260
|
-
* residue build
|
|
278
|
+
* (undefined outside the daemon), the index lock, the NullLogger for the
|
|
279
|
+
* residue build, the message command with its fixed time limit and the same
|
|
280
|
+
* stop signal (design 14.18), and the host's writer with its own limit and
|
|
281
|
+
* that signal when the library got one (design 14.19).
|
|
261
282
|
*/
|
|
262
|
-
function commitOf(parts, paths, config, runtime, hookStop) {
|
|
283
|
+
function commitOf(parts, paths, config, runtime, hookStop, commitMessage) {
|
|
263
284
|
const commitIndexPath = join(paths.stateDir, COMMIT_INDEX_FILE);
|
|
264
285
|
const commitFacade = new GitFacade(new GitProcessAdapter({
|
|
265
286
|
cwd: paths.root,
|
|
@@ -285,12 +306,27 @@ function commitOf(parts, paths, config, runtime, hookStop) {
|
|
|
285
306
|
},
|
|
286
307
|
realHooks,
|
|
287
308
|
indexLock: parts.indexLock,
|
|
309
|
+
messageCommand: new ShellMessageCommandAdapter({
|
|
310
|
+
cwd: paths.root,
|
|
311
|
+
baseEnv: runtime.env,
|
|
312
|
+
timeoutMs: MESSAGE_COMMAND_TIMEOUT_MS,
|
|
313
|
+
outputCapBytes: MESSAGE_COMMAND_OUTPUT_CAP_BYTES,
|
|
314
|
+
abort: hookStop,
|
|
315
|
+
}),
|
|
316
|
+
hostMessage: commitMessage === undefined
|
|
317
|
+
? null
|
|
318
|
+
: new HostMessageWriterAdapter(commitMessage, {
|
|
319
|
+
root: paths.root,
|
|
320
|
+
timeoutMs: HOST_MESSAGE_TIMEOUT_MS,
|
|
321
|
+
abort: hookStop,
|
|
322
|
+
}),
|
|
288
323
|
fs: parts.fs,
|
|
289
324
|
logger: parts.logger,
|
|
290
325
|
residueLogger: new NullLogger(),
|
|
291
326
|
clock: parts.clock,
|
|
292
327
|
settings: {
|
|
293
328
|
commitSubject: config.commitSubject,
|
|
329
|
+
commitMessageCommand: config.commitMessageCommand,
|
|
294
330
|
skipJsonCheck: config.skipJsonCheck,
|
|
295
331
|
partialStagingWaitMs: config.partialStagingWaitMs,
|
|
296
332
|
commitHooks: config.commitHooks,
|
|
@@ -303,16 +339,19 @@ function commitOf(parts, paths, config, runtime, hookStop) {
|
|
|
303
339
|
/**
|
|
304
340
|
* runCommand's deps with one process host shared by the daemon and its lock:
|
|
305
341
|
* the lock still records this process's pid and hostname, while stops come
|
|
306
|
-
* from requestStop() instead of the host program's signals.
|
|
342
|
+
* from requestStop() instead of the host program's signals. commitMessage is
|
|
343
|
+
* the host's writer, when it passed one (design 14.19).
|
|
307
344
|
*/
|
|
308
|
-
export function createEmbeddedRunDeps(runtime) {
|
|
345
|
+
export function createEmbeddedRunDeps(runtime, commitMessage) {
|
|
309
346
|
const processHost = new RequestedStopProcessHost(new NodeProcessAdapter());
|
|
347
|
+
const fs = new NodeFileSystemAdapter();
|
|
310
348
|
return {
|
|
311
349
|
deps: {
|
|
312
350
|
locator: createLocator(runtime),
|
|
313
|
-
fs
|
|
351
|
+
fs,
|
|
352
|
+
readGitConfig: gitConfigReaderFor(runtime, fs),
|
|
314
353
|
logger: new StderrLoggerAdapter(runtime.stderr, new SystemClockAdapter()),
|
|
315
|
-
createDaemon: (paths, config) => createDaemon(paths, config, runtime, processHost),
|
|
354
|
+
createDaemon: (paths, config) => createDaemon(paths, config, runtime, processHost, commitMessage),
|
|
316
355
|
},
|
|
317
356
|
requestStop: () => processHost.requestStop(),
|
|
318
357
|
};
|
package/dist/index.d.ts
CHANGED
|
@@ -10,7 +10,7 @@ export type { ExitCode } from './contracts/exitCode.js';
|
|
|
10
10
|
export { EXIT } from './contracts/exitCode.js';
|
|
11
11
|
export type { DaemonState, LastCycle, StateInstance, StateReason, StateRecord, StateTransition, } from './contracts/IStateStore.js';
|
|
12
12
|
export type { ITextSink } from './contracts/ITextSink.js';
|
|
13
|
-
export type { EnmetuInstance, EnmetuStartOptions, EnmetuStateView, StateLiveness, } from './contracts/library.js';
|
|
13
|
+
export type { CommitChange, CommitMessageWriter, EnmetuInstance, EnmetuStartOptions, EnmetuStateView, StateLiveness, } from './contracts/library.js';
|
|
14
14
|
export type { ConflictKey, CycleOutcomeKind, PauseReason, WaitReason, } from './contracts/syncTypes.js';
|
|
15
15
|
/** Starts enmetu for the repository containing options.repo, in this process. */
|
|
16
16
|
export declare function startEnmetu(options: EnmetuStartOptions): EnmetuInstance;
|
package/dist/index.js
CHANGED
|
@@ -21,6 +21,8 @@ function processRuntime() {
|
|
|
21
21
|
// records its own argv[1].
|
|
22
22
|
cliPath: fileURLToPath(new URL('./cli.js', import.meta.url)),
|
|
23
23
|
version: packageVersion(),
|
|
24
|
+
platform: process.platform,
|
|
25
|
+
engine: process.versions.bun === undefined ? 'node' : 'bun',
|
|
24
26
|
stdout: process.stdout,
|
|
25
27
|
stderr: process.stderr,
|
|
26
28
|
};
|
|
@@ -5,7 +5,8 @@ import type { Runtime } from '../contracts/runtime.js';
|
|
|
5
5
|
* without src/index.ts (which reads the process and its own module URL). startEmbedded
|
|
6
6
|
* runs runCommand with createEmbeddedRunDeps: the same locate, git version
|
|
7
7
|
* gate, config and daemon `enmetu run` uses, but with RequestedStopProcessHost,
|
|
8
|
-
* so stop() takes the place of the first SIGTERM
|
|
8
|
+
* so stop() takes the place of the first SIGTERM, and with the host's
|
|
9
|
+
* commitMessage writer when it passed one (design 14.19). options.repo is resolved
|
|
9
10
|
* against runtime.cwd. It returns at once; done resolves when the run ends and
|
|
10
11
|
* never rejects: an error runCommand throws is written to the log sink in the
|
|
11
12
|
* form `enmetu: <message>` and mapped by exitCodeFor, like the CLI's main, so a
|
|
@@ -9,7 +9,8 @@ import { readStateController } from './readStateController.js';
|
|
|
9
9
|
* without src/index.ts (which reads the process and its own module URL). startEmbedded
|
|
10
10
|
* runs runCommand with createEmbeddedRunDeps: the same locate, git version
|
|
11
11
|
* gate, config and daemon `enmetu run` uses, but with RequestedStopProcessHost,
|
|
12
|
-
* so stop() takes the place of the first SIGTERM
|
|
12
|
+
* so stop() takes the place of the first SIGTERM, and with the host's
|
|
13
|
+
* commitMessage writer when it passed one (design 14.19). options.repo is resolved
|
|
13
14
|
* against runtime.cwd. It returns at once; done resolves when the run ends and
|
|
14
15
|
* never rejects: an error runCommand throws is written to the log sink in the
|
|
15
16
|
* form `enmetu: <message>` and mapped by exitCodeFor, like the CLI's main, so a
|
|
@@ -17,7 +18,7 @@ import { readStateController } from './readStateController.js';
|
|
|
17
18
|
*/
|
|
18
19
|
export function startEmbedded(options, runtime) {
|
|
19
20
|
const embedded = embeddedRuntime(options, runtime);
|
|
20
|
-
const run = createEmbeddedRunDeps(embedded);
|
|
21
|
+
const run = createEmbeddedRunDeps(embedded, options.commitMessage);
|
|
21
22
|
const done = settledRun({ startDir: resolve(runtime.cwd, options.repo) }, run.deps, embedded.stderr);
|
|
22
23
|
return {
|
|
23
24
|
done,
|
|
@@ -41,6 +42,8 @@ function embeddedRuntime(options, runtime) {
|
|
|
41
42
|
execPath: runtime.execPath,
|
|
42
43
|
cliPath: runtime.cliPath,
|
|
43
44
|
version: runtime.version,
|
|
45
|
+
platform: runtime.platform,
|
|
46
|
+
engine: runtime.engine,
|
|
44
47
|
stdout: runtime.stdout,
|
|
45
48
|
stderr: options.log ?? runtime.stderr,
|
|
46
49
|
};
|
|
@@ -27,6 +27,16 @@ export declare class PathQuarantine implements IPathQuarantine {
|
|
|
27
27
|
constructor(git: IGitReader, writer: IGitWriter, fs: IFileSystem, paths: QuarantinePaths);
|
|
28
28
|
hold(paths: readonly string[]): Promise<HoldResult>;
|
|
29
29
|
extend(paths: readonly string[]): Promise<HoldResult>;
|
|
30
|
+
/**
|
|
31
|
+
* git refused the checkout (a person's merge left a held path unmerged,
|
|
32
|
+
* design 14.20): an entry whose file still holds the bytes stored for it was
|
|
33
|
+
* never set aside, and restoring it later would write those bytes over the
|
|
34
|
+
* person's newer work, conflict markers included. Such entries leave the
|
|
35
|
+
* memento (the memento goes when none is left); an entry whose file changed
|
|
36
|
+
* stays, so no write goes unrecorded. Entries before `from` (an earlier
|
|
37
|
+
* hold that extend added to) stay as they are.
|
|
38
|
+
*/
|
|
39
|
+
private forgetUnheld;
|
|
30
40
|
/**
|
|
31
41
|
* git's checkout writes the index's version over a file whatever the file
|
|
32
42
|
* holds, so a write that landed after storeEntries read it would be gone
|
|
@@ -70,7 +70,13 @@ export class PathQuarantine {
|
|
|
70
70
|
// --- Step 5: a file written since step 3 is stored again (Decision 114) ---
|
|
71
71
|
await this.storeAgainIfWritten(entries, []);
|
|
72
72
|
// --- Step 6: the held paths now match the index, so the rebase may run ---
|
|
73
|
-
|
|
73
|
+
try {
|
|
74
|
+
await this.writer.restoreFromIndex(paths);
|
|
75
|
+
}
|
|
76
|
+
catch (error) {
|
|
77
|
+
await this.forgetUnheld(0);
|
|
78
|
+
throw error;
|
|
79
|
+
}
|
|
74
80
|
return { kind: 'held', count: entries.length };
|
|
75
81
|
}
|
|
76
82
|
async extend(paths) {
|
|
@@ -102,9 +108,45 @@ export class PathQuarantine {
|
|
|
102
108
|
// --- Step 6: a file written since step 4 is stored again (Decision 114) ---
|
|
103
109
|
await this.storeAgainIfWritten(entries, earlier);
|
|
104
110
|
// --- Step 7: the captured paths now match the index ---
|
|
105
|
-
|
|
111
|
+
try {
|
|
112
|
+
await this.writer.restoreFromIndex(paths);
|
|
113
|
+
}
|
|
114
|
+
catch (error) {
|
|
115
|
+
await this.forgetUnheld(earlier.length);
|
|
116
|
+
throw error;
|
|
117
|
+
}
|
|
106
118
|
return { kind: 'held', count: entries.length };
|
|
107
119
|
}
|
|
120
|
+
/**
|
|
121
|
+
* git refused the checkout (a person's merge left a held path unmerged,
|
|
122
|
+
* design 14.20): an entry whose file still holds the bytes stored for it was
|
|
123
|
+
* never set aside, and restoring it later would write those bytes over the
|
|
124
|
+
* person's newer work, conflict markers included. Such entries leave the
|
|
125
|
+
* memento (the memento goes when none is left); an entry whose file changed
|
|
126
|
+
* stays, so no write goes unrecorded. Entries before `from` (an earlier
|
|
127
|
+
* hold that extend added to) stay as they are.
|
|
128
|
+
*/
|
|
129
|
+
async forgetUnheld(from) {
|
|
130
|
+
const text = await this.fs.readText(this.mementoPath());
|
|
131
|
+
const memento = text === null ? null : parseQuarantineMemento(text);
|
|
132
|
+
if (memento === null) {
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
const kept = memento.entries.slice(0, from);
|
|
136
|
+
for (const entry of memento.entries.slice(from)) {
|
|
137
|
+
const target = join(this.paths.root, entry.path);
|
|
138
|
+
const seen = await this.observe(entry.path, target);
|
|
139
|
+
if (seen !== (entry.blobId ?? OBSERVED_ABSENT)) {
|
|
140
|
+
kept.push(entry);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
if (kept.length === 0) {
|
|
144
|
+
await this.fs.remove(this.mementoPath());
|
|
145
|
+
}
|
|
146
|
+
else {
|
|
147
|
+
await this.writeMemento({ version: 1, entries: kept });
|
|
148
|
+
}
|
|
149
|
+
}
|
|
108
150
|
/**
|
|
109
151
|
* git's checkout writes the index's version over a file whatever the file
|
|
110
152
|
* holds, so a write that landed after storeEntries read it would be gone
|
|
@@ -6,7 +6,7 @@ import type { ILogger } from '../contracts/ILogger.js';
|
|
|
6
6
|
import type { IOperationJournal } from '../contracts/IOperationJournal.js';
|
|
7
7
|
import type { IPathQuarantine } from '../contracts/IPathQuarantine.js';
|
|
8
8
|
import type { IOrphanedCommitRecorder } from '../contracts/IStateRecorder.js';
|
|
9
|
-
import type { ConflictKey, PauseReason } from '../contracts/syncTypes.js';
|
|
9
|
+
import type { ConflictKey, HoldPolicy, PauseReason } from '../contracts/syncTypes.js';
|
|
10
10
|
export interface IntegrateUpstreamInput {
|
|
11
11
|
readonly cycleId: string;
|
|
12
12
|
/** The branch the cycle resolved its upstream for. */
|
|
@@ -16,6 +16,8 @@ export interface IntegrateUpstreamInput {
|
|
|
16
16
|
/** Commits the upstream has that HEAD lacks, for the PULLED line. */
|
|
17
17
|
readonly behind: number;
|
|
18
18
|
readonly knownConflict: ConflictKey | null;
|
|
19
|
+
/** Which dirty tracked paths step 2 may hold (design 14.21, Decision 147). */
|
|
20
|
+
readonly hold: HoldPolicy;
|
|
19
21
|
}
|
|
20
22
|
export interface IntegrateUpstreamDeps {
|
|
21
23
|
readonly git: IGitReader;
|
|
@@ -51,6 +53,14 @@ export type IntegrateUpstreamResult = {
|
|
|
51
53
|
} | {
|
|
52
54
|
readonly kind: 'halted';
|
|
53
55
|
readonly code: HaltCode;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Dirty tracked paths the hold policy does not name were found: saves made
|
|
59
|
+
* during the cycle. Nothing was held or rebased (design 14.21, Decision 147).
|
|
60
|
+
*/
|
|
61
|
+
| {
|
|
62
|
+
readonly kind: 'deferred';
|
|
63
|
+
readonly paths: readonly string[];
|
|
54
64
|
};
|
|
55
65
|
/**
|
|
56
66
|
* The integrate step with its deps bound, the shape the sync cycle consumes. The
|
|
@@ -63,7 +73,11 @@ export type IntegrateUpstream = (input: IntegrateUpstreamInput) => Promise<Integ
|
|
|
63
73
|
* paths held aside (design 1.11).
|
|
64
74
|
*
|
|
65
75
|
* 1. head = git.headCommit(); an identical knownConflict logs CONFLICT and returns it (Decision 11).
|
|
66
|
-
* 2. Dirty tracked paths:
|
|
76
|
+
* 2. Dirty tracked paths: any the hold policy does not name is a save made
|
|
77
|
+
* during the cycle, and the step returns deferred with those paths, nothing
|
|
78
|
+
* held or rebased (design 14.21, Decision 147). Otherwise HELD names the
|
|
79
|
+
* paths (Decision 148) and quarantine.hold runs; pending returns busy,
|
|
80
|
+
* unsupported pauses QUARANTINE_UNSUPPORTED.
|
|
67
81
|
* 3. From here on in try/finally:
|
|
68
82
|
* a. git.repositoryState() again: operation pauses FOREIGN_OPERATION, indexLocked returns busy,
|
|
69
83
|
* another branch pauses BRANCH_CHANGED.
|
|
@@ -9,7 +9,11 @@ const SHORT_SHA_LENGTH = 7;
|
|
|
9
9
|
* paths held aside (design 1.11).
|
|
10
10
|
*
|
|
11
11
|
* 1. head = git.headCommit(); an identical knownConflict logs CONFLICT and returns it (Decision 11).
|
|
12
|
-
* 2. Dirty tracked paths:
|
|
12
|
+
* 2. Dirty tracked paths: any the hold policy does not name is a save made
|
|
13
|
+
* during the cycle, and the step returns deferred with those paths, nothing
|
|
14
|
+
* held or rebased (design 14.21, Decision 147). Otherwise HELD names the
|
|
15
|
+
* paths (Decision 148) and quarantine.hold runs; pending returns busy,
|
|
16
|
+
* unsupported pauses QUARANTINE_UNSUPPORTED.
|
|
13
17
|
* 3. From here on in try/finally:
|
|
14
18
|
* a. git.repositoryState() again: operation pauses FOREIGN_OPERATION, indexLocked returns busy,
|
|
15
19
|
* another branch pauses BRANCH_CHANGED.
|
|
@@ -65,8 +69,24 @@ export async function integrateUpstreamController(input, deps) {
|
|
|
65
69
|
entry.worktree === 'D' ||
|
|
66
70
|
entry.worktree === 'T'))
|
|
67
71
|
.map((entry) => entry.path);
|
|
72
|
+
// A dirty path the commit step could have committed is a save that landed
|
|
73
|
+
// since its add -A: holding it would rewrite a file the person is editing
|
|
74
|
+
// (design 14.21). The pull waits for a cycle that commits it first.
|
|
75
|
+
const policy = input.hold;
|
|
76
|
+
const fresh = policy.kind === 'all'
|
|
77
|
+
? []
|
|
78
|
+
: dirty.filter((path) => !policy.paths.includes(path));
|
|
79
|
+
if (fresh.length > 0) {
|
|
80
|
+
return { kind: 'deferred', paths: fresh };
|
|
81
|
+
}
|
|
68
82
|
let held = false;
|
|
69
83
|
if (dirty.length > 0) {
|
|
84
|
+
deps.logger.log({
|
|
85
|
+
level: 'WARN',
|
|
86
|
+
code: 'HELD',
|
|
87
|
+
message: `set aside for the pull, showing the committed version until RESTORED: ${dirty.join(', ')}`,
|
|
88
|
+
cycleId,
|
|
89
|
+
});
|
|
70
90
|
const hold = await deps.quarantine.hold(dirty);
|
|
71
91
|
if (hold.kind === 'pending') {
|
|
72
92
|
return { kind: 'busy' };
|
|
@@ -42,7 +42,9 @@ export interface SyncCycleDeps {
|
|
|
42
42
|
* memory (design 12.7); busy and paused return, committed and nothing go
|
|
43
43
|
* on, waiting goes on and is the outcome unless a later step returns
|
|
44
44
|
* something else. The check right before the commit is the commit step's,
|
|
45
|
-
* under index.lock.
|
|
45
|
+
* under index.lock. The hold policy for step 9 (design 14.21): the commit
|
|
46
|
+
* step's after committed or nothing, all after a hook rejection, nothing
|
|
47
|
+
* after a clean status.
|
|
46
48
|
* 6. Remote check: ls-remote, tracking ref, verified ref (seeded from tracking when missing).
|
|
47
49
|
* 7. Verify and fetch: fetch into the incoming ref when the remote moved; the remote sha
|
|
48
50
|
* must descend from the verified sha, else halt UPSTREAM_REWRITTEN; then move the
|
|
@@ -53,6 +55,9 @@ export interface SyncCycleDeps {
|
|
|
53
55
|
* git.hasStagedChanges() says the real index differs from HEAD (git would
|
|
54
56
|
* refuse the rebase), integrate is skipped, UNPUSHED reported and waiting
|
|
55
57
|
* returned (Decision 84).
|
|
58
|
+
* deferred (a save made during the cycle is dirty): UNPUSHED reported,
|
|
59
|
+
* waiting TREE_CHANGING returned, the refusal count left as it is
|
|
60
|
+
* (Decision 147).
|
|
56
61
|
* not-started: the count of consecutive refusals (input.notStartedRebases)
|
|
57
62
|
* goes up by one; the first logs REBASE_NOT_STARTED with git's first stderr
|
|
58
63
|
* line; UNPUSHED is reported; the outcome is busy, or paused REBASE_REFUSED
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { EnmetuError } from '../contracts/errors.js';
|
|
2
|
-
import { EMPTY_COMMIT_MEMORY, } from '../contracts/syncTypes.js';
|
|
2
|
+
import { EMPTY_COMMIT_MEMORY, HOLD_NOTHING, } from '../contracts/syncTypes.js';
|
|
3
3
|
import { withLastMove } from '../core/commit/commitMemory.js';
|
|
4
4
|
import { withCycleId } from '../core/errors/withCycleId.js';
|
|
5
5
|
import { incomingRefFor, verifiedRefFor } from '../core/git/enmetuRefs.js';
|
|
@@ -28,7 +28,9 @@ export const REBASE_REFUSED_AFTER = 3;
|
|
|
28
28
|
* memory (design 12.7); busy and paused return, committed and nothing go
|
|
29
29
|
* on, waiting goes on and is the outcome unless a later step returns
|
|
30
30
|
* something else. The check right before the commit is the commit step's,
|
|
31
|
-
* under index.lock.
|
|
31
|
+
* under index.lock. The hold policy for step 9 (design 14.21): the commit
|
|
32
|
+
* step's after committed or nothing, all after a hook rejection, nothing
|
|
33
|
+
* after a clean status.
|
|
32
34
|
* 6. Remote check: ls-remote, tracking ref, verified ref (seeded from tracking when missing).
|
|
33
35
|
* 7. Verify and fetch: fetch into the incoming ref when the remote moved; the remote sha
|
|
34
36
|
* must descend from the verified sha, else halt UPSTREAM_REWRITTEN; then move the
|
|
@@ -39,6 +41,9 @@ export const REBASE_REFUSED_AFTER = 3;
|
|
|
39
41
|
* git.hasStagedChanges() says the real index differs from HEAD (git would
|
|
40
42
|
* refuse the rebase), integrate is skipped, UNPUSHED reported and waiting
|
|
41
43
|
* returned (Decision 84).
|
|
44
|
+
* deferred (a save made during the cycle is dirty): UNPUSHED reported,
|
|
45
|
+
* waiting TREE_CHANGING returned, the refusal count left as it is
|
|
46
|
+
* (Decision 147).
|
|
42
47
|
* not-started: the count of consecutive refusals (input.notStartedRebases)
|
|
43
48
|
* goes up by one; the first logs REBASE_NOT_STARTED with git's first stderr
|
|
44
49
|
* line; UNPUSHED is reported; the outcome is busy, or paused REBASE_REFUSED
|
|
@@ -115,6 +120,9 @@ async function runCycle(input, deps, carried, outcome) {
|
|
|
115
120
|
// The commit's parent when this cycle committed: step 9 records the pull
|
|
116
121
|
// from there, so the repair covers the commit and the pull in one move.
|
|
117
122
|
let committedFrom = null;
|
|
123
|
+
// Which dirty paths the pull may set aside (design 14.21): after a clean
|
|
124
|
+
// status none, so a path dirty at step 9 is a save made during the cycle.
|
|
125
|
+
let hold = HOLD_NOTHING;
|
|
118
126
|
if ((await deps.git.status()).length > 0) {
|
|
119
127
|
// A person's merge, cherry-pick or revert that stopped after step 2 is in
|
|
120
128
|
// that status; the commit step would only find it under index.lock (Decision 52).
|
|
@@ -135,6 +143,14 @@ async function runCycle(input, deps, carried, outcome) {
|
|
|
135
143
|
}
|
|
136
144
|
if (committed.kind === 'waiting') {
|
|
137
145
|
waiting = committed.reason;
|
|
146
|
+
// A rejected commit leaves every dirty path uncommittable; a rejecting
|
|
147
|
+
// hook must not block incoming changes (D4), so the pull holds them all.
|
|
148
|
+
if (committed.reason === 'COMMIT_HOOK_REJECTED') {
|
|
149
|
+
hold = { kind: 'all' };
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
if (committed.kind === 'committed' || committed.kind === 'nothing') {
|
|
153
|
+
hold = committed.hold;
|
|
138
154
|
}
|
|
139
155
|
if (committed.kind === 'committed') {
|
|
140
156
|
committedFrom = committed.memory.lastMove?.from ?? null;
|
|
@@ -217,7 +233,13 @@ async function runCycle(input, deps, carried, outcome) {
|
|
|
217
233
|
ontoSha: remoteSha,
|
|
218
234
|
behind,
|
|
219
235
|
knownConflict: input.knownConflict,
|
|
236
|
+
hold,
|
|
220
237
|
});
|
|
238
|
+
if (integrated.kind === 'deferred') {
|
|
239
|
+
// The next cycle commits the save first; the runner logs WAITING once.
|
|
240
|
+
reportUnpushed(ahead, upstream, cycleId, deps);
|
|
241
|
+
return outcome({ kind: 'waiting', reason: 'TREE_CHANGING' });
|
|
242
|
+
}
|
|
221
243
|
if (integrated.kind === 'not-started') {
|
|
222
244
|
const refusals = (carried.notStartedRebases ?? 0) + 1;
|
|
223
245
|
carried.notStartedRebases = refusals;
|
|
@@ -3,6 +3,7 @@ import { errorMessage } from '../core/errors/errorMessage.js';
|
|
|
3
3
|
const WAITING_MESSAGES = {
|
|
4
4
|
PARTIAL_SELECTION_STAGED: 'waiting: a partial selection is staged; nothing is committed or pulled until you commit or unstage it, and after partialStagingWaitMs enmetu commits everything',
|
|
5
5
|
COMMIT_HOOK_REJECTED: 'waiting: a commit hook rejected the commit; enmetu tries again when the files change',
|
|
6
|
+
TREE_CHANGING: 'waiting: a file was saved while this cycle ran; the pull waits for a cycle that finds nothing new to commit, so no file you are editing is set aside',
|
|
6
7
|
};
|
|
7
8
|
/**
|
|
8
9
|
* Service: the memory that spans cycles. Numbers cycles c1, c2, ...; passes
|