@fluado/enmetu 0.3.0 → 0.5.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 (38) hide show
  1. package/README.md +26 -20
  2. package/dist/adapters/git/gitFacade.d.ts +3 -6
  3. package/dist/adapters/git/gitFacade.js +6 -2
  4. package/dist/adapters/git/gitProcessAdapter.js +2 -1
  5. package/dist/cli/commands/runCommand.d.ts +25 -3
  6. package/dist/cli/commands/runCommand.js +43 -6
  7. package/dist/cli/exitCodes.js +1 -0
  8. package/dist/cli.js +12 -6
  9. package/dist/commit/commitLocalChangesController.d.ts +15 -5
  10. package/dist/commit/commitLocalChangesController.js +21 -14
  11. package/dist/commit/stageGuardedChanges.d.ts +3 -1
  12. package/dist/commit/stageGuardedChanges.js +5 -4
  13. package/dist/contracts/IGitRunner.d.ts +7 -0
  14. package/dist/contracts/ISyncCycle.d.ts +8 -0
  15. package/dist/contracts/errors.d.ts +1 -1
  16. package/dist/contracts/library.d.ts +1 -1
  17. package/dist/contracts/logCodes.d.ts +1 -1
  18. package/dist/contracts/runtime.d.ts +26 -5
  19. package/dist/contracts/syncTypes.d.ts +27 -5
  20. package/dist/contracts/syncTypes.js +2 -0
  21. package/dist/core/quarantine/restoreWarnings.d.ts +6 -3
  22. package/dist/core/quarantine/restoreWarnings.js +12 -3
  23. package/dist/core/state/stateGuards.js +1 -0
  24. package/dist/core/status/statusHeadline.js +3 -0
  25. package/dist/enmetuInstances.d.ts +3 -3
  26. package/dist/enmetuInstances.js +6 -4
  27. package/dist/hooks/hookScript.d.ts +6 -11
  28. package/dist/hooks/hookScript.js +35 -11
  29. package/dist/hooks/preCommitHookInstaller.d.ts +12 -10
  30. package/dist/hooks/preCommitHookInstaller.js +23 -16
  31. package/dist/index.js +8 -4
  32. package/dist/library/enmetuLibrary.js +2 -2
  33. package/dist/sync/integrateUpstreamController.d.ts +16 -2
  34. package/dist/sync/integrateUpstreamController.js +21 -1
  35. package/dist/sync/syncCycleController.d.ts +9 -1
  36. package/dist/sync/syncCycleController.js +69 -4
  37. package/dist/sync/syncCycleRunner.js +6 -2
  38. package/package.json +3 -2
@@ -1,20 +1,41 @@
1
1
  import type { ITextSink } from './ITextSink.js';
2
- /** Everything the process gives enmetu, gathered once by src/cli.ts. */
2
+ /**
3
+ * How this enmetu is started again, recorded in the pre-commit hook (designs
4
+ * 1.7, 13.1, 14.22).
5
+ */
6
+ export type Launch =
7
+ /**
8
+ * An interpreter and the CLI script: node or bun with dist/cli.js. The
9
+ * interpreter is process.execPath, absolute under Node, a bare name under a
10
+ * Bun started by name (13.1); the script is process.argv[1], which Node
11
+ * makes absolute.
12
+ */
13
+ {
14
+ readonly kind: 'script';
15
+ readonly interpreter: string;
16
+ readonly script: string;
17
+ }
18
+ /** The standalone executable of design 14.22 (process.execPath): it runs its own hook check. */
19
+ | {
20
+ readonly kind: 'executable';
21
+ readonly path: string;
22
+ };
23
+ /** Everything the process gives enmetu, gathered once by an entry (src/cli.ts, src/index.ts, src/standalone.ts). */
3
24
  export interface Runtime {
4
25
  /** Arguments after the node binary and the script. */
5
26
  readonly argv: readonly string[];
6
27
  /** Base environment for every git child. */
7
28
  readonly env: Readonly<Record<string, string | undefined>>;
8
29
  readonly cwd: string;
9
- /** Absolute node binary, recorded in the hook. */
10
- readonly execPath: string;
11
- /** Absolute path of the CLI script (Node resolves argv[1]), recorded in the hook. */
12
- readonly cliPath: string;
30
+ /** Recorded in the hook. */
31
+ readonly launch: Launch;
13
32
  readonly version: string;
14
33
  /** process.platform: 'linux', 'darwin', 'win32', ... */
15
34
  readonly platform: string;
16
35
  /** The JavaScript runtime enmetu runs on: 'bun' when process.versions.bun is set. */
17
36
  readonly engine: 'node' | 'bun';
37
+ /** Its version: process.versions.bun under Bun, process.versions.node under Node (design 14.25). */
38
+ readonly engineVersion: string;
18
39
  readonly stdout: ITextSink;
19
40
  readonly stderr: ITextSink;
20
41
  }
@@ -11,11 +11,30 @@ export type PauseReason = 'FOREIGN_OPERATION' | 'DETACHED_HEAD' | 'NO_UPSTREAM'
11
11
  */
12
12
  | 'REBASE_REFUSED';
13
13
  /**
14
- * Why a cycle committed nothing although the tree has changes (design 12.7):
15
- * a person has a selection staged and partialStagingWaitMs has not passed
16
- * (design 14.17), or a repository commit hook rejected enmetu's commit.
14
+ * Why a cycle did less than everything although the tree has changes (design
15
+ * 12.7): a person has a selection staged and partialStagingWaitMs has not
16
+ * passed (design 14.17), a repository commit hook rejected enmetu's commit, or
17
+ * a file was saved while the cycle ran and the pull waits for a cycle that
18
+ * finds nothing new to commit (design 14.21, Decision 147).
17
19
  */
18
- export type WaitReason = 'PARTIAL_SELECTION_STAGED' | 'COMMIT_HOOK_REJECTED';
20
+ export type WaitReason = 'PARTIAL_SELECTION_STAGED' | 'COMMIT_HOOK_REJECTED' | 'TREE_CHANGING';
21
+ /**
22
+ * Which dirty tracked paths integrate may set aside for a pull (design 14.21,
23
+ * Decision 147): the ones the commit step could not commit this cycle. Any
24
+ * other dirty path is a save that landed during the cycle, and the pull waits.
25
+ */
26
+ export type HoldPolicy =
27
+ /** Exactly these paths (the content guard's EXCLUDED paths); empty when the cycle committed everything or found nothing. */
28
+ {
29
+ readonly kind: 'only';
30
+ readonly paths: readonly string[];
31
+ }
32
+ /** Every dirty tracked path: a commit hook rejected the commit, or the hooks' residue is all that changed. */
33
+ | {
34
+ readonly kind: 'all';
35
+ };
36
+ /** The policy after a clean status: any dirty path at the pull is a save made during the cycle. */
37
+ export declare const HOLD_NOTHING: HoldPolicy;
19
38
  export type CycleOutcome = {
20
39
  readonly kind: 'synced';
21
40
  } | {
@@ -23,8 +42,11 @@ export type CycleOutcome = {
23
42
  readonly reason: PauseReason;
24
43
  } | {
25
44
  readonly kind: 'busy';
26
- } | {
45
+ }
46
+ /** The remote could not be reached; detail is git's first line of reason (design 14.24, Decision 159). */
47
+ | {
27
48
  readonly kind: 'offline';
49
+ readonly detail: string;
28
50
  } | {
29
51
  readonly kind: 'conflict';
30
52
  } | {
@@ -1,3 +1,5 @@
1
+ /** The policy after a clean status: any dirty path at the pull is a save made during the cycle. */
2
+ export const HOLD_NOTHING = { kind: 'only', paths: [] };
1
3
  export const EMPTY_COMMIT_MEMORY = {
2
4
  partialStagingSince: null,
3
5
  rejection: null,
@@ -2,6 +2,8 @@ import type { ILogger } from '../../contracts/ILogger.js';
2
2
  import type { KeptBlob } from '../../contracts/IPathQuarantine.js';
3
3
  /** What a restore reports that a person should see; the fields of RestoreResult's restored. */
4
4
  export interface RestoreWarnings {
5
+ /** Paths put back; 0 logs no RESTORED line (design 14.21). */
6
+ readonly count: number;
5
7
  readonly kept: readonly KeptBlob[];
6
8
  readonly changedUnderHold: readonly string[];
7
9
  readonly overwritten: readonly KeptBlob[];
@@ -9,8 +11,9 @@ export interface RestoreWarnings {
9
11
  /** The QUARANTINE_UPSTREAM_CHANGED message (Decision 99). */
10
12
  export declare function changedUnderHoldMessage(path: string): string;
11
13
  /**
12
- * One WARN per kept blob and per path restored over a change, the same at
13
- * every restore: recovery, a pending memento, and integrate (written three
14
- * times before it was extracted).
14
+ * One INFO RESTORED with the count of paths put back (Decision 148), then one
15
+ * WARN per kept blob and per path restored over a change, the same at every
16
+ * restore: recovery, a pending memento, and integrate (written three times
17
+ * before it was extracted).
15
18
  */
16
19
  export declare function logRestoreWarnings(warnings: RestoreWarnings, cycleId: string, logger: ILogger): void;
@@ -4,11 +4,20 @@ export function changedUnderHoldMessage(path) {
4
4
  return `${path} changed in HEAD while it was held (a pulled change) and the held edits do not merge cleanly onto it; the held version is back in the working tree, so committing it undoes that change (compare with git diff HEAD -- ${path})`;
5
5
  }
6
6
  /**
7
- * One WARN per kept blob and per path restored over a change, the same at
8
- * every restore: recovery, a pending memento, and integrate (written three
9
- * times before it was extracted).
7
+ * One INFO RESTORED with the count of paths put back (Decision 148), then one
8
+ * WARN per kept blob and per path restored over a change, the same at every
9
+ * restore: recovery, a pending memento, and integrate (written three times
10
+ * before it was extracted).
10
11
  */
11
12
  export function logRestoreWarnings(warnings, cycleId, logger) {
13
+ if (warnings.count > 0) {
14
+ logger.log({
15
+ level: 'INFO',
16
+ code: 'RESTORED',
17
+ message: `${warnings.count === 1 ? '1 held path' : `${warnings.count} held paths`} put back`,
18
+ cycleId,
19
+ });
20
+ }
12
21
  for (const kept of warnings.kept) {
13
22
  logger.log({
14
23
  level: 'WARN',
@@ -169,6 +169,7 @@ const STATE_REASONS = [
169
169
  'REBASE_REFUSED',
170
170
  'PARTIAL_SELECTION_STAGED',
171
171
  'COMMIT_HOOK_REJECTED',
172
+ 'TREE_CHANGING',
172
173
  ...HALT_CODES,
173
174
  ];
174
175
  const OUTCOME_KINDS = [
@@ -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}`;
@@ -31,9 +31,9 @@ export declare function createRecoveryDeps(paths: RepositoryPaths, config: Enmet
31
31
  */
32
32
  export declare function createDaemon(paths: RepositoryPaths, config: EnmetuConfig, runtime: Runtime, processHost?: IProcessHost | undefined, commitMessage?: CommitMessageWriter | undefined): IDaemon;
33
33
  /**
34
- * The daemon's hook installer for one repository (design 13.1): it records
35
- * Runtime.execPath, resolved through the PATH enmetu was started with when it
36
- * is a bare name, and Runtime.cliPath.
34
+ * The daemon's hook installer for one repository (designs 13.1, 14.22): it
35
+ * records Runtime.launch, its interpreter or executable resolved through the
36
+ * PATH enmetu was started with when it is a bare name.
37
37
  */
38
38
  export declare function createHookInstaller(paths: RepositoryPaths, config: EnmetuConfig, runtime: Runtime): IHookInstaller;
39
39
  export declare function createRunDeps(runtime: Runtime): RunCommandDeps;
@@ -107,9 +107,9 @@ export function createDaemon(paths, config, runtime, processHost, commitMessage)
107
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);
108
108
  }
109
109
  /**
110
- * The daemon's hook installer for one repository (design 13.1): it records
111
- * Runtime.execPath, resolved through the PATH enmetu was started with when it
112
- * is a bare name, and Runtime.cliPath.
110
+ * The daemon's hook installer for one repository (designs 13.1, 14.22): it
111
+ * records Runtime.launch, its interpreter or executable resolved through the
112
+ * PATH enmetu was started with when it is a bare name.
113
113
  */
114
114
  export function createHookInstaller(paths, config, runtime) {
115
115
  return hookInstallerOf(guardedRepositoryParts(paths, config.gitTimeoutMs, runtime), paths, config, runtime);
@@ -117,7 +117,7 @@ export function createHookInstaller(paths, config, runtime) {
117
117
  // Null Object: the daemon has no branch for installHook (section 2).
118
118
  function hookInstallerOf(parts, paths, config, runtime) {
119
119
  return config.installHook
120
- ? new PreCommitHookInstaller(parts.git, parts.fs, paths.hooksDir, { nodePath: runtime.execPath, cliPath: runtime.cliPath }, runtime.env.PATH)
120
+ ? new PreCommitHookInstaller(parts.git, parts.fs, paths.hooksDir, runtime.launch, runtime.env.PATH)
121
121
  : new NullHookInstaller();
122
122
  }
123
123
  export function createRunDeps(runtime) {
@@ -127,6 +127,7 @@ export function createRunDeps(runtime) {
127
127
  fs,
128
128
  readGitConfig: gitConfigReaderFor(runtime, fs),
129
129
  logger: new StderrLoggerAdapter(runtime.stderr, new SystemClockAdapter()),
130
+ engine: { kind: runtime.engine, version: runtime.engineVersion },
130
131
  createDaemon: (paths, config) => createDaemon(paths, config, runtime),
131
132
  };
132
133
  }
@@ -351,6 +352,7 @@ export function createEmbeddedRunDeps(runtime, commitMessage) {
351
352
  fs,
352
353
  readGitConfig: gitConfigReaderFor(runtime, fs),
353
354
  logger: new StderrLoggerAdapter(runtime.stderr, new SystemClockAdapter()),
355
+ engine: { kind: runtime.engine, version: runtime.engineVersion },
354
356
  createDaemon: (paths, config) => createDaemon(paths, config, runtime, processHost, commitMessage),
355
357
  },
356
358
  requestStop: () => processHost.requestStop(),
@@ -1,15 +1,10 @@
1
+ import type { Launch } from '../contracts/runtime.js';
1
2
  export declare const HOOK_MARKER = "# enmetu-managed-hook v1";
2
- export interface HookScriptPaths {
3
- /**
4
- * The interpreter recorded at install time: an absolute path, or the bare
5
- * name no search path entry held (design 13.1).
6
- */
7
- readonly nodePath: string;
8
- /** Absolute enmetu CLI script recorded at install time. */
9
- readonly cliPath: string;
10
- }
11
- /** The POSIX sh pre-commit hook of design 1.7; a single quote becomes '\''. */
12
- export declare function renderHookScript(paths: HookScriptPaths): string;
3
+ /**
4
+ * The POSIX sh pre-commit hook of design 1.7 in the shape the launch needs
5
+ * (design 14.22); a single quote becomes '\''.
6
+ */
7
+ export declare function renderHookScript(launch: Launch): string;
13
8
  /** true when the content carries the enmetu marker line. */
14
9
  export declare function isEnmetuHook(content: string): boolean;
15
10
  /**
@@ -1,14 +1,24 @@
1
1
  export const HOOK_MARKER = '# enmetu-managed-hook v1';
2
- /** The POSIX sh pre-commit hook of design 1.7; a single quote becomes '\''. */
3
- export function renderHookScript(paths) {
2
+ const HEADER = [
3
+ '#!/bin/sh',
4
+ HOOK_MARKER,
5
+ '# Written by enmetu at startup; set "installHook": false in enmetu.config.json to stop that.',
6
+ '# Refuses a commit that records conflict markers or a .json file that does not parse.',
7
+ '# To commit anyway: git commit --no-verify',
8
+ ];
9
+ /**
10
+ * The POSIX sh pre-commit hook of design 1.7 in the shape the launch needs
11
+ * (design 14.22); a single quote becomes '\''.
12
+ */
13
+ export function renderHookScript(launch) {
14
+ const body = launch.kind === 'script' ? scriptForm(launch) : executableForm(launch);
15
+ return [...HEADER, ...body, ''].join('\n');
16
+ }
17
+ /** The interpreter and the CLI script, as src/cli.ts and src/index.ts record them. */
18
+ function scriptForm(launch) {
4
19
  return [
5
- '#!/bin/sh',
6
- HOOK_MARKER,
7
- '# Written by enmetu at startup; set "installHook": false in enmetu.config.json to stop that.',
8
- '# Refuses a commit that records conflict markers or a .json file that does not parse.',
9
- '# To commit anyway: git commit --no-verify',
10
- `ENMETU_NODE=${shellQuote(paths.nodePath)}`,
11
- `ENMETU_CLI=${shellQuote(paths.cliPath)}`,
20
+ `ENMETU_NODE=${shellQuote(launch.interpreter)}`,
21
+ `ENMETU_CLI=${shellQuote(launch.script)}`,
12
22
  // The recorded interpreter may vanish with an upgrade (nvm, a moved Bun)
13
23
  // or be a bare name no PATH entry held at install time: the same name on
14
24
  // the commit's PATH keeps a Bun-only machine checking, node on PATH is
@@ -26,8 +36,22 @@ export function renderHookScript(paths) {
26
36
  'fi',
27
37
  'echo "enmetu: pre-commit check skipped (node or enmetu not found)" >&2',
28
38
  'exit 0',
29
- '',
30
- ].join('\n');
39
+ ];
40
+ }
41
+ /** The standalone executable, which runs its own check (design 14.22, Decision 151). */
42
+ function executableForm(launch) {
43
+ return [
44
+ `ENMETU_BIN=${shellQuote(launch.path)}`,
45
+ // The recorded file may be gone (reinstalled elsewhere, another clone of
46
+ // the hook's machine): the same name on the commit's PATH, then enmetu on
47
+ // PATH, then fail open, the shape of Decision 102's line.
48
+ '[ -x "$ENMETU_BIN" ] || ENMETU_BIN="$(command -v "${ENMETU_BIN##*/}" 2>/dev/null || command -v enmetu 2>/dev/null)"',
49
+ 'if [ -n "$ENMETU_BIN" ]; then',
50
+ ' exec "$ENMETU_BIN" hook pre-commit',
51
+ 'fi',
52
+ 'echo "enmetu: pre-commit check skipped (enmetu not found)" >&2',
53
+ 'exit 0',
54
+ ];
31
55
  }
32
56
  /** true when the content carries the enmetu marker line. */
33
57
  export function isEnmetuHook(content) {
@@ -1,32 +1,34 @@
1
1
  import type { IFileSystem } from '../contracts/IFileSystem.js';
2
2
  import type { IGitConfigReader } from '../contracts/IGitConfigReader.js';
3
3
  import type { HookInstallResult, IHookInstaller } from '../contracts/IHookInstaller.js';
4
- import { type HookScriptPaths } from './hookScript.js';
4
+ import type { Launch } from '../contracts/runtime.js';
5
5
  /**
6
6
  * Service: installs <hooksDir>/pre-commit following rules 1 to 5 of design
7
7
  * 1.7 (D10). Reads git configuration only through IGitConfigReader, so it
8
8
  * cannot write any; never overwrites a hook without the enmetu marker.
9
9
  * A bare-name interpreter (Bun started by name reports the bare name "bun"
10
- * for its own executable path) is resolved through searchPath before the
11
- * hook is rendered (design 13.1, Decision 102).
10
+ * for its own executable path) or executable is resolved through searchPath
11
+ * before the hook is rendered (designs 13.1 and 14.22, Decision 102).
12
12
  */
13
13
  export declare class PreCommitHookInstaller implements IHookInstaller {
14
14
  private readonly config;
15
15
  private readonly fs;
16
16
  private readonly hooksDir;
17
- private readonly script;
17
+ private readonly launch;
18
18
  /** The PATH the daemon was started with (Runtime.env.PATH); absent: no resolution. */
19
19
  private readonly searchPath?;
20
- constructor(config: IGitConfigReader, fs: IFileSystem, hooksDir: string, script: HookScriptPaths,
20
+ constructor(config: IGitConfigReader, fs: IFileSystem, hooksDir: string, launch: Launch,
21
21
  /** The PATH the daemon was started with (Runtime.env.PATH); absent: no resolution. */
22
22
  searchPath?: string | undefined);
23
23
  ensure(): Promise<HookInstallResult>;
24
24
  private install;
25
+ /** The launch with its interpreter or executable resolved for the record. */
26
+ private resolvedLaunch;
25
27
  /**
26
- * The interpreter to record: an absolute nodePath unchanged; for a bare
27
- * name, <dir>/<name> for the first absolute search path entry where that
28
- * is an executable file or a link; else the bare name, which the hook
29
- * looks up on the commit's PATH.
28
+ * The file to record: an absolute name unchanged; for a bare name,
29
+ * <dir>/<name> for the first absolute search path entry where that is an
30
+ * executable file or a link; else the bare name, which the hook looks up
31
+ * on the commit's PATH.
30
32
  */
31
- private interpreterPath;
33
+ private resolve;
32
34
  }
@@ -9,22 +9,22 @@ const OTHER_LANGUAGE_NOTE = 'A hook in another language runs "enmetu hook pre-co
9
9
  * 1.7 (D10). Reads git configuration only through IGitConfigReader, so it
10
10
  * cannot write any; never overwrites a hook without the enmetu marker.
11
11
  * A bare-name interpreter (Bun started by name reports the bare name "bun"
12
- * for its own executable path) is resolved through searchPath before the
13
- * hook is rendered (design 13.1, Decision 102).
12
+ * for its own executable path) or executable is resolved through searchPath
13
+ * before the hook is rendered (designs 13.1 and 14.22, Decision 102).
14
14
  */
15
15
  export class PreCommitHookInstaller {
16
16
  config;
17
17
  fs;
18
18
  hooksDir;
19
- script;
19
+ launch;
20
20
  searchPath;
21
- constructor(config, fs, hooksDir, script,
21
+ constructor(config, fs, hooksDir, launch,
22
22
  /** The PATH the daemon was started with (Runtime.env.PATH); absent: no resolution. */
23
23
  searchPath) {
24
24
  this.config = config;
25
25
  this.fs = fs;
26
26
  this.hooksDir = hooksDir;
27
- this.script = script;
27
+ this.launch = launch;
28
28
  this.searchPath = searchPath;
29
29
  }
30
30
  async ensure() {
@@ -73,11 +73,8 @@ export class PreCommitHookInstaller {
73
73
  instruction: `${target} exists and is not enmetu's. To add enmetu's check, add this line to it: ${pathInvocationLine()}. ${OTHER_LANGUAGE_NOTE}`,
74
74
  };
75
75
  }
76
- // 4. An enmetu hook is rewritten only when the recorded paths changed.
77
- const rendered = renderHookScript({
78
- nodePath: await this.interpreterPath(),
79
- cliPath: this.script.cliPath,
80
- });
76
+ // 4. An enmetu hook is rewritten only when the recorded launch changed.
77
+ const rendered = renderHookScript(await this.resolvedLaunch());
81
78
  if (existing === rendered) {
82
79
  return { kind: 'unchanged' };
83
80
  }
@@ -89,14 +86,24 @@ export class PreCommitHookInstaller {
89
86
  });
90
87
  return { kind: existing === null ? 'installed' : 'updated' };
91
88
  }
89
+ /** The launch with its interpreter or executable resolved for the record. */
90
+ async resolvedLaunch() {
91
+ if (this.launch.kind === 'script') {
92
+ return {
93
+ kind: 'script',
94
+ interpreter: await this.resolve(this.launch.interpreter),
95
+ script: this.launch.script,
96
+ };
97
+ }
98
+ return { kind: 'executable', path: await this.resolve(this.launch.path) };
99
+ }
92
100
  /**
93
- * The interpreter to record: an absolute nodePath unchanged; for a bare
94
- * name, <dir>/<name> for the first absolute search path entry where that
95
- * is an executable file or a link; else the bare name, which the hook
96
- * looks up on the commit's PATH.
101
+ * The file to record: an absolute name unchanged; for a bare name,
102
+ * <dir>/<name> for the first absolute search path entry where that is an
103
+ * executable file or a link; else the bare name, which the hook looks up
104
+ * on the commit's PATH.
97
105
  */
98
- async interpreterPath() {
99
- const name = this.script.nodePath;
106
+ async resolve(name) {
100
107
  if (name.includes('/') || this.searchPath === undefined) {
101
108
  return name;
102
109
  }
package/dist/index.js CHANGED
@@ -16,13 +16,17 @@ function processRuntime() {
16
16
  argv: [],
17
17
  env: process.env,
18
18
  cwd: process.cwd(),
19
- execPath: process.execPath,
20
- // The CLI ships next to this file; the hook records it like `enmetu run`
21
- // records its own argv[1].
22
- cliPath: fileURLToPath(new URL('./cli.js', import.meta.url)),
19
+ launch: {
20
+ kind: 'script',
21
+ interpreter: process.execPath,
22
+ // The CLI ships next to this file; the hook records it like
23
+ // `enmetu run` records its own argv[1].
24
+ script: fileURLToPath(new URL('./cli.js', import.meta.url)),
25
+ },
23
26
  version: packageVersion(),
24
27
  platform: process.platform,
25
28
  engine: process.versions.bun === undefined ? 'node' : 'bun',
29
+ engineVersion: process.versions.bun ?? process.versions.node,
26
30
  stdout: process.stdout,
27
31
  stderr: process.stderr,
28
32
  };
@@ -39,11 +39,11 @@ function embeddedRuntime(options, runtime) {
39
39
  argv: [],
40
40
  env: options.env ?? runtime.env,
41
41
  cwd: runtime.cwd,
42
- execPath: runtime.execPath,
43
- cliPath: runtime.cliPath,
42
+ launch: runtime.launch,
44
43
  version: runtime.version,
45
44
  platform: runtime.platform,
46
45
  engine: runtime.engine,
46
+ engineVersion: runtime.engineVersion,
47
47
  stdout: runtime.stdout,
48
48
  stderr: options.log ?? runtime.stderr,
49
49
  };
@@ -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: quarantine.hold; pending returns busy, unsupported pauses QUARANTINE_UNSUPPORTED.
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: quarantine.hold; pending returns busy, unsupported pauses QUARANTINE_UNSUPPORTED.
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,12 @@ 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), and pullDeferred carried to the next cycle: while it is
61
+ * carried, a cycle that committed reports UNPUSHED and returns waiting
62
+ * TREE_CHANGING before integrate; the first cycle that commits nothing
63
+ * pulls and clears it (Decision 162).
56
64
  * not-started: the count of consecutive refusals (input.notStartedRebases)
57
65
  * goes up by one; the first logs REBASE_NOT_STARTED with git's first stderr
58
66
  * line; UNPUSHED is reported; the outcome is busy, or paused REBASE_REFUSED