@fluado/enmetu 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/README.md +99 -10
  2. package/dist/adapters/git/gitFacade.d.ts +8 -0
  3. package/dist/adapters/git/gitFacade.js +34 -1
  4. package/dist/adapters/git/gitLocatorFacade.js +6 -3
  5. package/dist/adapters/git/gitProcessAdapter.js +1 -48
  6. package/dist/adapters/host/hostMessageWriterAdapter.d.ts +23 -0
  7. package/dist/adapters/host/hostMessageWriterAdapter.js +76 -0
  8. package/dist/adapters/process/processGroup.d.ts +18 -0
  9. package/dist/adapters/process/processGroup.js +52 -0
  10. package/dist/adapters/process/shellMessageCommandAdapter.d.ts +24 -0
  11. package/dist/adapters/process/shellMessageCommandAdapter.js +102 -0
  12. package/dist/adapters/watch/watcherFor.d.ts +22 -0
  13. package/dist/adapters/watch/watcherFor.js +29 -0
  14. package/dist/cli/commands/hookCommand.d.ts +3 -1
  15. package/dist/cli/commands/hookCommand.js +15 -7
  16. package/dist/cli/commands/runCommand.d.ts +10 -2
  17. package/dist/cli/commands/runCommand.js +51 -5
  18. package/dist/cli/exitCodes.js +0 -1
  19. package/dist/cli.js +2 -0
  20. package/dist/commit/commitLocalChangesController.d.ts +28 -7
  21. package/dist/commit/commitLocalChangesController.js +135 -27
  22. package/dist/contracts/ICommitMessageCommand.d.ts +32 -0
  23. package/dist/contracts/ICommitMessageCommand.js +1 -0
  24. package/dist/contracts/IGitConfigReader.d.ts +4 -1
  25. package/dist/contracts/IGitReader.d.ts +5 -0
  26. package/dist/contracts/IHostMessageWriter.d.ts +28 -0
  27. package/dist/contracts/IHostMessageWriter.js +1 -0
  28. package/dist/contracts/IRepositoryLocator.d.ts +5 -1
  29. package/dist/contracts/config.d.ts +44 -5
  30. package/dist/contracts/errors.d.ts +7 -3
  31. package/dist/contracts/errors.js +5 -1
  32. package/dist/contracts/library.d.ts +30 -0
  33. package/dist/contracts/logCodes.d.ts +1 -1
  34. package/dist/contracts/runtime.d.ts +4 -0
  35. package/dist/contracts/syncTypes.d.ts +3 -3
  36. package/dist/contracts/syncTypes.js +1 -1
  37. package/dist/core/commit/commitMemory.js +1 -1
  38. package/dist/core/commit/commitMessage.d.ts +27 -0
  39. package/dist/core/commit/commitMessage.js +61 -0
  40. package/dist/core/config/parseConfig.d.ts +9 -3
  41. package/dist/core/config/parseConfig.js +186 -60
  42. package/dist/core/git/porcelain.d.ts +9 -1
  43. package/dist/core/git/porcelain.js +27 -3
  44. package/dist/core/status/statusHeadline.js +1 -1
  45. package/dist/enmetuInstances.d.ts +7 -4
  46. package/dist/enmetuInstances.js +53 -15
  47. package/dist/hooks/preCommitHookInstaller.js +10 -0
  48. package/dist/index.d.ts +1 -1
  49. package/dist/index.js +2 -0
  50. package/dist/library/enmetuLibrary.d.ts +2 -1
  51. package/dist/library/enmetuLibrary.js +5 -2
  52. package/dist/quarantine/pathQuarantine.d.ts +10 -0
  53. package/dist/quarantine/pathQuarantine.js +44 -2
  54. package/dist/sync/syncCycleController.d.ts +1 -1
  55. package/dist/sync/syncCycleController.js +2 -2
  56. package/dist/sync/syncCycleRunner.js +1 -1
  57. package/package.json +1 -1
@@ -173,11 +173,35 @@ export function parseRevParsePaths(stdout) {
173
173
  if (lines.length !== 3 || !lines.every((line) => line.startsWith('/'))) {
174
174
  return null;
175
175
  }
176
- const [root, gitDir, hooksDir] = lines;
177
- if (root === undefined || gitDir === undefined || hooksDir === undefined) {
176
+ const [root, gitDir, commonDir] = lines;
177
+ if (root === undefined || gitDir === undefined || commonDir === undefined) {
178
178
  return null;
179
179
  }
180
- return { root, gitDir, hooksDir };
180
+ return { root, gitDir, commonDir };
181
+ }
182
+ /**
183
+ * `git config -z --show-scope --get-regexp` entries: scope NUL, then the key,
184
+ * a newline and the value, NUL; a key written without a value has no newline
185
+ * (measured with git 2.52.0, design 14.18).
186
+ */
187
+ export function parseConfigEntries(stdout) {
188
+ const fields = nulTerminatedFields(stdout, 'git config');
189
+ if (fields.length % 2 !== 0) {
190
+ throw unparseable('git config', fields.join('\0'));
191
+ }
192
+ const entries = [];
193
+ for (let i = 0; i < fields.length; i += 2) {
194
+ const scope = fields[i] ?? '';
195
+ const field = fields[i + 1] ?? '';
196
+ const newline = field.indexOf('\n');
197
+ const key = newline === -1 ? field : field.slice(0, newline);
198
+ if (scope === '' || key === '') {
199
+ throw unparseable('git config', `${scope}\0${field}`);
200
+ }
201
+ const value = newline === -1 ? null : field.slice(newline + 1);
202
+ entries.push({ scope, key, value });
203
+ }
204
+ return entries;
181
205
  }
182
206
  /** true when `git push --porcelain` output carries a ref line starting with `!`. */
183
207
  export function hasRejectedRef(stdout) {
@@ -64,7 +64,7 @@ function runningHeadline(record, uncommitted, unpushed) {
64
64
  return `attention: pushes failing since ${since}, ${work}`;
65
65
  case 'waiting':
66
66
  if (record.reason === 'PARTIAL_SELECTION_STAGED') {
67
- return `waiting: a partial selection is staged since ${since}; nothing is committed or pulled until it is committed or unstaged, ${work}`;
67
+ return `waiting: a partial selection is staged since ${since}; nothing is committed or pulled until you commit or unstage it, and after partialStagingWaitMs enmetu commits everything, ${work}`;
68
68
  }
69
69
  if (record.reason === 'COMMIT_HOOK_REJECTED') {
70
70
  return `waiting: a commit hook rejected the last commit 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;
@@ -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 { FsWatchAdapter, nodeWatch } from './adapters/watch/fsWatchAdapter.js';
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, new FsWatchAdapter(paths.root, nodeWatch), scheduler, host, parts.logger, state, parts.clock);
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: new NodeFileSystemAdapter(),
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 and the NullLogger for the
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,15 +306,29 @@ 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
- stagedSelection: config.stagedSelection,
296
- stagedSelectionMaxWaitMs: config.stagedSelectionMaxWaitMs,
331
+ partialStagingWaitMs: config.partialStagingWaitMs,
297
332
  commitHooks: config.commitHooks,
298
333
  realIndex: join(paths.gitDir, 'index'),
299
334
  root: paths.root,
@@ -304,16 +339,19 @@ function commitOf(parts, paths, config, runtime, hookStop) {
304
339
  /**
305
340
  * runCommand's deps with one process host shared by the daemon and its lock:
306
341
  * the lock still records this process's pid and hostname, while stops come
307
- * 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).
308
344
  */
309
- export function createEmbeddedRunDeps(runtime) {
345
+ export function createEmbeddedRunDeps(runtime, commitMessage) {
310
346
  const processHost = new RequestedStopProcessHost(new NodeProcessAdapter());
347
+ const fs = new NodeFileSystemAdapter();
311
348
  return {
312
349
  deps: {
313
350
  locator: createLocator(runtime),
314
- fs: new NodeFileSystemAdapter(),
351
+ fs,
352
+ readGitConfig: gitConfigReaderFor(runtime, fs),
315
353
  logger: new StderrLoggerAdapter(runtime.stderr, new SystemClockAdapter()),
316
- createDaemon: (paths, config) => createDaemon(paths, config, runtime, processHost),
354
+ createDaemon: (paths, config) => createDaemon(paths, config, runtime, processHost, commitMessage),
317
355
  },
318
356
  requestStop: () => processHost.requestStop(),
319
357
  };
@@ -45,6 +45,16 @@ export class PreCommitHookInstaller {
45
45
  // repository sharing it); writing there would install enmetu into all
46
46
  // of them, so the person adds the line themselves.
47
47
  const hooksPath = await this.config.configValue('core.hooksPath');
48
+ if (hooksPath === '') {
49
+ // git looks for an empty hooks path's hooks at /<name>, so no hook of
50
+ // the repository runs (measured with git 2.52.0, design 14.17); there
51
+ // is no hook to add the line to.
52
+ return {
53
+ kind: 'skipped',
54
+ reason: 'HOOKS_PATH_SET',
55
+ instruction: 'core.hooksPath is empty, so git runs no hooks and enmetu installs none; unset it to have enmetu install its pre-commit check.',
56
+ };
57
+ }
48
58
  if (hooksPath !== null) {
49
59
  return {
50
60
  kind: 'skipped',
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. options.repo is resolved
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. options.repo is resolved
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
- await this.writer.restoreFromIndex(paths);
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
- await this.writer.restoreFromIndex(paths);
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
@@ -37,7 +37,7 @@ export interface SyncCycleDeps {
37
37
  * a local one (remote '.') pauses UPSTREAM_NOT_REMOTE (Decision 28).
38
38
  * 4. Pending quarantine memento: restore it; failed halts QUARANTINE_RESTORE_FAILED.
39
39
  * 5. Commit local changes, skipped on a clean status (which also clears the
40
- * memory's stagedSelectionSince): after the status read the repository
40
+ * memory's partialStagingSince): after the status read the repository
41
41
  * state is read again (Decision 52), then deps.commit runs with the commit
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
@@ -23,7 +23,7 @@ export const REBASE_REFUSED_AFTER = 3;
23
23
  * a local one (remote '.') pauses UPSTREAM_NOT_REMOTE (Decision 28).
24
24
  * 4. Pending quarantine memento: restore it; failed halts QUARANTINE_RESTORE_FAILED.
25
25
  * 5. Commit local changes, skipped on a clean status (which also clears the
26
- * memory's stagedSelectionSince): after the status read the repository
26
+ * memory's partialStagingSince): after the status read the repository
27
27
  * state is read again (Decision 52), then deps.commit runs with the commit
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
@@ -143,7 +143,7 @@ async function runCycle(input, deps, carried, outcome) {
143
143
  else if (carried.commitMemory !== undefined) {
144
144
  // A clean tree has no staged selection, so a wait for one is over.
145
145
  carried.commitMemory = {
146
- stagedSelectionSince: null,
146
+ partialStagingSince: null,
147
147
  rejection: carried.commitMemory.rejection,
148
148
  residueTree: carried.commitMemory.residueTree,
149
149
  lastMove: carried.commitMemory.lastMove,
@@ -1,7 +1,7 @@
1
1
  import { EMPTY_COMMIT_MEMORY, } from '../contracts/syncTypes.js';
2
2
  import { errorMessage } from '../core/errors/errorMessage.js';
3
3
  const WAITING_MESSAGES = {
4
- PARTIAL_SELECTION_STAGED: 'waiting: a partial selection is staged; nothing is committed or pulled until it is committed or unstaged',
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
6
  };
7
7
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fluado/enmetu",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
4
  "description": "Keeps one git working tree committed and in sync with its upstream.",
5
5
  "license": "MIT",
6
6
  "author": "fluado UG (haftungsbeschränkt)",