@fluado/enmetu 0.2.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 (46) hide show
  1. package/README.md +53 -5
  2. package/dist/adapters/git/gitFacade.d.ts +8 -0
  3. package/dist/adapters/git/gitFacade.js +34 -1
  4. package/dist/adapters/git/gitProcessAdapter.js +1 -48
  5. package/dist/adapters/host/hostMessageWriterAdapter.d.ts +23 -0
  6. package/dist/adapters/host/hostMessageWriterAdapter.js +76 -0
  7. package/dist/adapters/process/processGroup.d.ts +18 -0
  8. package/dist/adapters/process/processGroup.js +52 -0
  9. package/dist/adapters/process/shellMessageCommandAdapter.d.ts +24 -0
  10. package/dist/adapters/process/shellMessageCommandAdapter.js +102 -0
  11. package/dist/adapters/watch/watcherFor.d.ts +22 -0
  12. package/dist/adapters/watch/watcherFor.js +29 -0
  13. package/dist/cli/commands/hookCommand.d.ts +2 -1
  14. package/dist/cli/commands/hookCommand.js +14 -8
  15. package/dist/cli/commands/runCommand.d.ts +10 -3
  16. package/dist/cli/commands/runCommand.js +45 -9
  17. package/dist/cli.js +2 -0
  18. package/dist/commit/commitLocalChangesController.d.ts +22 -1
  19. package/dist/commit/commitLocalChangesController.js +113 -4
  20. package/dist/contracts/ICommitMessageCommand.d.ts +32 -0
  21. package/dist/contracts/ICommitMessageCommand.js +1 -0
  22. package/dist/contracts/IGitConfigReader.d.ts +4 -1
  23. package/dist/contracts/IGitReader.d.ts +5 -0
  24. package/dist/contracts/IHostMessageWriter.d.ts +28 -0
  25. package/dist/contracts/IHostMessageWriter.js +1 -0
  26. package/dist/contracts/config.d.ts +34 -1
  27. package/dist/contracts/errors.d.ts +5 -1
  28. package/dist/contracts/errors.js +5 -1
  29. package/dist/contracts/library.d.ts +30 -0
  30. package/dist/contracts/logCodes.d.ts +1 -1
  31. package/dist/contracts/runtime.d.ts +4 -0
  32. package/dist/core/commit/commitMessage.d.ts +27 -0
  33. package/dist/core/commit/commitMessage.js +61 -0
  34. package/dist/core/config/parseConfig.d.ts +7 -4
  35. package/dist/core/config/parseConfig.js +172 -26
  36. package/dist/core/git/porcelain.d.ts +7 -0
  37. package/dist/core/git/porcelain.js +24 -0
  38. package/dist/enmetuInstances.d.ts +7 -4
  39. package/dist/enmetuInstances.js +52 -13
  40. package/dist/index.d.ts +1 -1
  41. package/dist/index.js +2 -0
  42. package/dist/library/enmetuLibrary.d.ts +2 -1
  43. package/dist/library/enmetuLibrary.js +5 -2
  44. package/dist/quarantine/pathQuarantine.d.ts +10 -0
  45. package/dist/quarantine/pathQuarantine.js +44 -2
  46. package/package.json +1 -1
@@ -7,7 +7,8 @@ const HOOK_CYCLE_ID = 'hook';
7
7
  /**
8
8
  * Controller:
9
9
  * 1. locator.locate(startDir); anything but found: notice, exit 0 (the hook fails open).
10
- * 2. parseConfig of <root>/enmetu.config.json; a ConfigError prints a notice and
10
+ * 2. parseConfig of <root>/enmetu.config.json with git config over it, as the
11
+ * daemon reads it (design 14.18); a ConfigError prints a notice and
11
12
  * applies the defaults, so a broken config never blocks the commit that fixes it.
12
13
  * Unknown keys are ignored without a word: the daemon names them at start (design 14.17).
13
14
  * 3. preCommitCheckController with the config's skipJsonCheck.
@@ -21,10 +22,11 @@ export async function hookCommand(input, deps) {
21
22
  return { exitCode: EXIT.OK };
22
23
  }
23
24
  const { paths } = location;
24
- // --- Step 2: the config at the root; defaults when it is broken ---
25
- const config = await configForCheck(paths.root, deps);
25
+ const git = deps.openGit(paths);
26
+ // --- Step 2: the config at the root and git config; defaults when it is broken ---
27
+ const config = await configForCheck(paths.root, git, deps);
26
28
  // --- Step 3: the shared content rules over the staged blobs (D11) ---
27
- const { violations } = await preCommitCheckController({ cycleId: HOOK_CYCLE_ID }, { git: deps.openGit(paths), skipJsonCheck: config.skipJsonCheck });
29
+ const { violations } = await preCommitCheckController({ cycleId: HOOK_CYCLE_ID }, { git, skipJsonCheck: config.skipJsonCheck });
28
30
  // --- Step 4: the refusal, or silence ---
29
31
  if (violations.length === 0) {
30
32
  return { exitCode: EXIT.OK };
@@ -56,16 +58,20 @@ async function locateOrReason(startDir, deps) {
56
58
  };
57
59
  }
58
60
  }
59
- async function configForCheck(root, deps) {
61
+ async function configForCheck(root, git, deps) {
62
+ const text = await deps.fs.readText(join(root, CONFIG_FILE_NAME));
63
+ const gitEntries = await git.enmetuEntries();
60
64
  try {
61
- return parseConfig(await deps.fs.readText(join(root, CONFIG_FILE_NAME)))
62
- .config;
65
+ return parseConfig(text, gitEntries).config;
63
66
  }
64
67
  catch (error) {
65
68
  if (!(error instanceof ConfigError)) {
66
69
  throw error;
67
70
  }
68
- deps.stderr.write(`enmetu: ignoring ${CONFIG_FILE_NAME} (${error.code}: ${error.message}); using the defaults for this check\n`);
71
+ const source = error.gitScope === null
72
+ ? CONFIG_FILE_NAME
73
+ : `git config enmetu.${error.key ?? ''} (${error.gitScope})`;
74
+ deps.stderr.write(`enmetu: ignoring ${source} (${error.code}: ${error.message}); using the defaults for this check\n`);
69
75
  return DEFAULT_CONFIG;
70
76
  }
71
77
  }
@@ -1,4 +1,4 @@
1
- import type { EnmetuConfig } from '../../contracts/config.js';
1
+ import type { EnmetuConfig, GitConfigEntry } from '../../contracts/config.js';
2
2
  import { type ExitCode } from '../../contracts/exitCode.js';
3
3
  import type { IDaemon } from '../../contracts/IDaemon.js';
4
4
  import type { IFileSystem } from '../../contracts/IFileSystem.js';
@@ -11,6 +11,8 @@ export interface RunCommandInput {
11
11
  export interface RunCommandDeps {
12
12
  readonly locator: IRepositoryLocator;
13
13
  readonly fs: IFileSystem;
14
+ /** Every enmetu.* entry of git config for the resolved repository (design 14.18). */
15
+ readonly readGitConfig: (paths: RepositoryPaths) => Promise<readonly GitConfigEntry[]>;
14
16
  readonly logger: ILogger;
15
17
  /** Composition-root closure; builds the daemon for the resolved repository. */
16
18
  readonly createDaemon: (paths: RepositoryPaths, config: EnmetuConfig) => IDaemon;
@@ -27,8 +29,13 @@ export declare const MIN_GIT_VERSION: {
27
29
  * Controller:
28
30
  * 1. locator.locate(startDir): not a repository, bare, or PATH_UNSUPPORTED log NOT_ELIGIBLE, exit 5.
29
31
  * 2. Git older than 2.38: NOT_ELIGIBLE (GIT_TOO_OLD), exit 5.
30
- * 3. parseConfig of <root>/enmetu.config.json: ConfigError logs CONFIG_INVALID, exit 2;
31
- * each unknown key logs CONFIG_KEY_IGNORED once and is ignored (design 14.17).
32
+ * 3. parseConfig of <root>/enmetu.config.json and readGitConfig(paths), git
33
+ * config overriding the file (design 14.18): ConfigError logs
34
+ * CONFIG_INVALID naming the file or the git config key and scope, exit 2;
35
+ * each unknown key of the file, each key of the file only git config may
36
+ * set and each unknown enmetu.* key of git config logs CONFIG_KEY_IGNORED
37
+ * once and is ignored (design 14.17); each setting git config set logs
38
+ * CONFIG_FROM_GIT with its scope.
32
39
  * 4. createDaemon(paths, config).run(), its exit code returned.
33
40
  */
34
41
  export declare function runCommand(input: RunCommandInput, deps: RunCommandDeps): Promise<RunCommandResult>;
@@ -8,8 +8,13 @@ export const MIN_GIT_VERSION = { major: 2, minor: 38 };
8
8
  * Controller:
9
9
  * 1. locator.locate(startDir): not a repository, bare, or PATH_UNSUPPORTED log NOT_ELIGIBLE, exit 5.
10
10
  * 2. Git older than 2.38: NOT_ELIGIBLE (GIT_TOO_OLD), exit 5.
11
- * 3. parseConfig of <root>/enmetu.config.json: ConfigError logs CONFIG_INVALID, exit 2;
12
- * each unknown key logs CONFIG_KEY_IGNORED once and is ignored (design 14.17).
11
+ * 3. parseConfig of <root>/enmetu.config.json and readGitConfig(paths), git
12
+ * config overriding the file (design 14.18): ConfigError logs
13
+ * CONFIG_INVALID naming the file or the git config key and scope, exit 2;
14
+ * each unknown key of the file, each key of the file only git config may
15
+ * set and each unknown enmetu.* key of git config logs CONFIG_KEY_IGNORED
16
+ * once and is ignored (design 14.17); each setting git config set logs
17
+ * CONFIG_FROM_GIT with its scope.
13
18
  * 4. createDaemon(paths, config).run(), its exit code returned.
14
19
  */
15
20
  export async function runCommand(input, deps) {
@@ -35,34 +40,65 @@ export async function runCommand(input, deps) {
35
40
  if (olderThanMinimum(version)) {
36
41
  return notEligible(deps, 'GIT_TOO_OLD', `git ${version.major}.${version.minor}.${version.patch} is older than ${MIN_GIT_VERSION.major}.${MIN_GIT_VERSION.minor}`);
37
42
  }
38
- // --- Step 3: the config at the resolved root, defaults when absent ---
43
+ // --- Step 3: the config at the resolved root, then git config over it ---
39
44
  const configPath = join(paths.root, CONFIG_FILE_NAME);
40
45
  let parsed;
41
46
  try {
42
- parsed = parseConfig(await deps.fs.readText(configPath));
47
+ parsed = parseConfig(await deps.fs.readText(configPath), await deps.readGitConfig(paths));
43
48
  }
44
49
  catch (error) {
45
50
  if (!(error instanceof ConfigError)) {
46
51
  throw error;
47
52
  }
53
+ const source = error.gitScope === null
54
+ ? configPath
55
+ : `git config enmetu.${error.key ?? ''} (${error.gitScope})`;
48
56
  deps.logger.log({
49
57
  level: 'ERROR',
50
58
  code: 'CONFIG_INVALID',
51
- message: `${configPath}: ${error.code}: ${error.message}`,
59
+ message: `${source}: ${error.code}: ${error.message}`,
52
60
  });
53
61
  return { exitCode: EXIT.USAGE };
54
62
  }
63
+ logConfigSources(parsed, deps.logger);
64
+ const { config } = parsed;
65
+ // --- Step 4: the daemon for this repository ---
66
+ return { exitCode: await deps.createDaemon(paths, config).run() };
67
+ }
68
+ /**
69
+ * Step 3's lines, before the daemon starts: what was ignored, then every
70
+ * setting git config set, so nobody wonders why one clone behaves differently.
71
+ */
72
+ function logConfigSources(parsed, logger) {
55
73
  // A key only a newer enmetu knows must not stop this one (design 14.17).
56
74
  for (const key of parsed.unknownKeys) {
57
- deps.logger.log({
75
+ logger.log({
58
76
  level: 'WARN',
59
77
  code: 'CONFIG_KEY_IGNORED',
60
78
  message: `${CONFIG_FILE_NAME}: unknown setting "${key}", ignored`,
61
79
  });
62
80
  }
63
- const { config } = parsed;
64
- // --- Step 4: the daemon for this repository ---
65
- return { exitCode: await deps.createDaemon(paths, config).run() };
81
+ for (const key of parsed.gitOnlyKeysInFile) {
82
+ logger.log({
83
+ level: 'WARN',
84
+ code: 'CONFIG_KEY_IGNORED',
85
+ message: `${CONFIG_FILE_NAME}: ${key} is read from git config only, ignored`,
86
+ });
87
+ }
88
+ for (const { key, scope } of parsed.unknownGitKeys) {
89
+ logger.log({
90
+ level: 'WARN',
91
+ code: 'CONFIG_KEY_IGNORED',
92
+ message: `git config (${scope}): unknown setting "${key}", ignored`,
93
+ });
94
+ }
95
+ for (const { key, scope, shown } of parsed.fromGit) {
96
+ logger.log({
97
+ level: 'INFO',
98
+ code: 'CONFIG_FROM_GIT',
99
+ message: `${key} ${shown} (git config, ${scope})`,
100
+ });
101
+ }
66
102
  }
67
103
  function olderThanMinimum(version) {
68
104
  return (version.major < MIN_GIT_VERSION.major ||
package/dist/cli.js CHANGED
@@ -26,6 +26,8 @@ const runtime = {
26
26
  // an empty path, finds no CLI there and lets commits through (fail open).
27
27
  cliPath: process.argv[1] ?? '',
28
28
  version: packageVersion(),
29
+ platform: process.platform,
30
+ engine: process.versions.bun === undefined ? 'node' : 'bun',
29
31
  stdout: process.stdout,
30
32
  stderr: process.stderr,
31
33
  };
@@ -1,13 +1,17 @@
1
1
  import type { IClock } from '../contracts/IClock.js';
2
+ import type { ICommitMessageCommand } from '../contracts/ICommitMessageCommand.js';
2
3
  import type { IFileSystem } from '../contracts/IFileSystem.js';
3
4
  import type { IGitCommitter, IGitHookRunner } from '../contracts/IGitCommitter.js';
4
5
  import type { IGitIndexLock } from '../contracts/IGitIndexLock.js';
5
6
  import type { IGitReader } from '../contracts/IGitReader.js';
6
7
  import type { IGitWriter } from '../contracts/IGitWriter.js';
8
+ import type { IHostMessageWriter } from '../contracts/IHostMessageWriter.js';
7
9
  import type { ILogger } from '../contracts/ILogger.js';
8
10
  import type { CommitMemory, WaitReason } from '../contracts/syncTypes.js';
9
11
  /** An unchanged tree a hook rejected is offered to the hooks again after this long (design 12.7). */
10
12
  export declare const HOOK_RETRY_MS = 600000;
13
+ /** commitMessageCommand reads at most this much of the patch on stdin (design 14.18). */
14
+ export declare const MESSAGE_PATCH_CAP_BYTES = 65536;
11
15
  export interface CommitLocalChangesInput {
12
16
  readonly cycleId: string;
13
17
  readonly memory: CommitMemory;
@@ -31,6 +35,8 @@ export interface CommitIndexGit {
31
35
  }
32
36
  export interface CommitSettings {
33
37
  readonly commitSubject: string;
38
+ /** null: the message is commitSubject alone (design 14.18). */
39
+ readonly commitMessageCommand: string | null;
34
40
  readonly skipJsonCheck: readonly string[];
35
41
  /** How long a partial staging holds the commit back; 0 commits at once (design 14.17). */
36
42
  readonly partialStagingWaitMs: number;
@@ -49,6 +55,10 @@ export interface CommitLocalChangesDeps {
49
55
  /** Hooks on the real index: post-commit and post-index-change run after the branch moved. */
50
56
  readonly realHooks: IGitHookRunner;
51
57
  readonly indexLock: IGitIndexLock;
58
+ /** Runs commitMessageCommand; called only when it is set (design 14.18). */
59
+ readonly messageCommand: ICommitMessageCommand;
60
+ /** The host program's writer (library option commitMessage); null for the CLI (design 14.19). */
61
+ readonly hostMessage: IHostMessageWriter | null;
52
62
  readonly fs: IFileSystem;
53
63
  readonly logger: ILogger;
54
64
  /** NullLogger from the root: the residue build must not log EXCLUDED a second time. */
@@ -105,9 +115,20 @@ export type CommitLocalChanges = (input: CommitLocalChangesInput) => Promise<Com
105
115
  * 4. commitHooks: pre-commit on the commit index (rejected, timed out or
106
116
  * killed: HOOK_REJECTED, waiting, rejection remembered); then re-stage and
107
117
  * write the tree again, so a formatter's rewrite is in this commit. The
108
- * message is commitSubject plus a newline, with or without hooks; the
109
118
  * message hooks do not run (Decision 80). The re-stage logs through
110
119
  * residueLogger: its EXCLUDED lines repeat the first build's.
120
+ * 4b. The message (design 14.18, 14.19), before index.lock, so no writer
121
+ * ever holds up a person's git. The command's message: when
122
+ * commitMessageCommand is set, messageCommand runs with
123
+ * git.patch(parent, tree, MESSAGE_PATCH_CAP_BYTES) on stdin and
124
+ * messageFromText turns its stdout into the message; a failed patch, a
125
+ * failed command or output messageFromText refuses log one
126
+ * MESSAGE_COMMAND_FAILED line and give none. Without hostMessage: the
127
+ * command's message, else commitSubject plus a newline. With hostMessage:
128
+ * its write decides, given a runCommand that yields the command's message,
129
+ * run at most once however often it is called; declined is commitSubject;
130
+ * failed, or text messageFromText refuses, logs MESSAGE_WRITER_FAILED and
131
+ * is commitSubject.
111
132
  * 5. commit = committer.commitTree({ tree, parent, message, sign: gitBoolean(commit.gpgSign) }).
112
133
  * 6. Critical section: indexLock.acquire(commit) (false: busy);
113
134
  * 6.1 repositoryState() with an operation: release, paused; HEAD naming
@@ -1,10 +1,12 @@
1
1
  import { EnmetuError, GitCommandError } from '../contracts/errors.js';
2
2
  import { withLastMove } from '../core/commit/commitMemory.js';
3
- import { gitBoolean } from '../core/commit/commitMessage.js';
3
+ import { gitBoolean, messageFromText } from '../core/commit/commitMessage.js';
4
4
  import { repairStaleIndexController } from './repairStaleIndexController.js';
5
5
  import { stageGuardedChanges } from './stageGuardedChanges.js';
6
6
  /** An unchanged tree a hook rejected is offered to the hooks again after this long (design 12.7). */
7
7
  export const HOOK_RETRY_MS = 600000;
8
+ /** commitMessageCommand reads at most this much of the patch on stdin (design 14.18). */
9
+ export const MESSAGE_PATCH_CAP_BYTES = 65536;
8
10
  /**
9
11
  * Controller: enmetu's commit without touching the person's index (design
10
12
  * 12.7, Decisions 72 to 74, 80, 81).
@@ -28,9 +30,20 @@ export const HOOK_RETRY_MS = 600000;
28
30
  * 4. commitHooks: pre-commit on the commit index (rejected, timed out or
29
31
  * killed: HOOK_REJECTED, waiting, rejection remembered); then re-stage and
30
32
  * write the tree again, so a formatter's rewrite is in this commit. The
31
- * message is commitSubject plus a newline, with or without hooks; the
32
33
  * message hooks do not run (Decision 80). The re-stage logs through
33
34
  * residueLogger: its EXCLUDED lines repeat the first build's.
35
+ * 4b. The message (design 14.18, 14.19), before index.lock, so no writer
36
+ * ever holds up a person's git. The command's message: when
37
+ * commitMessageCommand is set, messageCommand runs with
38
+ * git.patch(parent, tree, MESSAGE_PATCH_CAP_BYTES) on stdin and
39
+ * messageFromText turns its stdout into the message; a failed patch, a
40
+ * failed command or output messageFromText refuses log one
41
+ * MESSAGE_COMMAND_FAILED line and give none. Without hostMessage: the
42
+ * command's message, else commitSubject plus a newline. With hostMessage:
43
+ * its write decides, given a runCommand that yields the command's message,
44
+ * run at most once however often it is called; declined is commitSubject;
45
+ * failed, or text messageFromText refuses, logs MESSAGE_WRITER_FAILED and
46
+ * is commitSubject.
34
47
  * 5. commit = committer.commitTree({ tree, parent, message, sign: gitBoolean(commit.gpgSign) }).
35
48
  * 6. Critical section: indexLock.acquire(commit) (false: busy);
36
49
  * 6.1 repositoryState() with an operation: release, paused; HEAD naming
@@ -148,12 +161,15 @@ export async function commitLocalChangesController(input, deps) {
148
161
  }
149
162
  tree = await deps.commitIndex.committer.writeTree();
150
163
  }
164
+ // --- Step 4b: the message, outside the lock ---
165
+ const message = await commitMessage(parent, tree, cycleId, deps);
151
166
  // --- Step 5: the commit object; HEAD is its only parent, never MERGE_HEAD ---
152
- // The subject is the whole message: no path ever reaches it (D1).
167
+ // enmetu itself puts no path into the message (D1); a person's
168
+ // commitMessageCommand may (design 14.18).
153
169
  const commit = await deps.commitIndex.committer.commitTree({
154
170
  tree,
155
171
  parent,
156
- message: `${settings.commitSubject}\n`,
172
+ message,
157
173
  sign: gitBoolean(await deps.git.configValue('commit.gpgSign')),
158
174
  });
159
175
  // --- Step 6: critical section under git's own index.lock ---
@@ -212,6 +228,99 @@ async function runPostCommitHooks(commit, tree, cycleId, deps) {
212
228
  const residue = await deps.commitIndex.committer.writeTree();
213
229
  return residue === tree ? null : residue;
214
230
  }
231
+ /**
232
+ * Step 4b (design 14.18, 14.19): the message. Without a host writer, the
233
+ * person's commitMessageCommand's, else commitSubject. With one, the
234
+ * writer's: it decides, and the person's command runs only when the writer
235
+ * asks for it, at most once. Messages are optional and cosmetic, so nothing
236
+ * about them ever stops a commit: every failure falls back to commitSubject
237
+ * with one line.
238
+ */
239
+ async function commitMessage(parent, tree, cycleId, deps) {
240
+ const subjectOnly = `${deps.settings.commitSubject}\n`;
241
+ if (deps.hostMessage === null) {
242
+ return (await commandMessage(parent, tree, cycleId, deps)) ?? subjectOnly;
243
+ }
244
+ let command = null;
245
+ const run = await deps.hostMessage.write({
246
+ parent,
247
+ tree,
248
+ runCommand: () => {
249
+ command ??= commandMessage(parent, tree, cycleId, deps);
250
+ return command;
251
+ },
252
+ });
253
+ if (run.kind === 'declined') {
254
+ return subjectOnly;
255
+ }
256
+ let failure;
257
+ if (run.kind === 'failed') {
258
+ failure = run.detail;
259
+ }
260
+ else {
261
+ const parsed = messageFromText(run.text);
262
+ if (parsed.kind === 'message') {
263
+ return parsed.message;
264
+ }
265
+ failure = `returned ${parsed.reason}`;
266
+ }
267
+ deps.logger.log({
268
+ level: 'WARN',
269
+ code: 'MESSAGE_WRITER_FAILED',
270
+ message: `the host program's commitMessage ${failure}; committing with commitSubject`,
271
+ cycleId,
272
+ });
273
+ return subjectOnly;
274
+ }
275
+ /**
276
+ * commitMessageCommand on the patch from the parent to the tree (design
277
+ * 14.18, Decision 131): its message, or null when the setting is unset or
278
+ * anything failed, a failure logged once as MESSAGE_COMMAND_FAILED naming
279
+ * what comes instead: commitSubject, or the host writer's own message.
280
+ */
281
+ async function commandMessage(parent, tree, cycleId, deps) {
282
+ const { commitMessageCommand } = deps.settings;
283
+ if (commitMessageCommand === null) {
284
+ return null;
285
+ }
286
+ let failure;
287
+ try {
288
+ const stdin = await deps.git.patch(parent, tree, MESSAGE_PATCH_CAP_BYTES);
289
+ const run = await deps.messageCommand.run({
290
+ command: commitMessageCommand,
291
+ stdin,
292
+ parent,
293
+ tree,
294
+ });
295
+ if (run.kind === 'failed') {
296
+ failure = run.detail;
297
+ }
298
+ else {
299
+ const parsed = messageFromText(run.stdout);
300
+ if (parsed.kind === 'message') {
301
+ return parsed.message;
302
+ }
303
+ failure = `printed ${parsed.reason}`;
304
+ }
305
+ }
306
+ catch (error) {
307
+ if (!(error instanceof GitCommandError)) {
308
+ throw error;
309
+ }
310
+ failure = `got no patch (${error.message})`;
311
+ }
312
+ // With a host writer the fallback is the writer's, not commitSubject.
313
+ const next = deps.hostMessage === null
314
+ ? 'committing with commitSubject'
315
+ : "the host program's commitMessage writes the message";
316
+ deps.logger.log({
317
+ level: 'WARN',
318
+ code: 'MESSAGE_COMMAND_FAILED',
319
+ message: `commitMessageCommand ${failure}; ${next}`,
320
+ cycleId,
321
+ });
322
+ return null;
323
+ }
215
324
  /**
216
325
  * Step 2 of the chain: copy the real index (git's stat data and skip-worktree
217
326
  * bits, so add -A does not re-hash every file), seed it with the commit's
@@ -0,0 +1,32 @@
1
+ export interface CommitMessageCommandInput {
2
+ /** commitMessageCommand, run by the shell in the repository root. */
3
+ readonly command: string;
4
+ /** Written to the command's stdin, then stdin is closed. */
5
+ readonly stdin: Buffer;
6
+ /** The commit's parent, as ENMETU_PARENT. */
7
+ readonly parent: string;
8
+ /** The commit's tree, as ENMETU_TREE. */
9
+ readonly tree: string;
10
+ }
11
+ export type CommitMessageCommandRun =
12
+ /** Exit code 0; stdout up to the output cap. */
13
+ {
14
+ readonly kind: 'printed';
15
+ readonly stdout: string;
16
+ }
17
+ /**
18
+ * Not started, a non-zero exit, a signal, the time limit, or a stop
19
+ * request; detail says which, with the first line of stderr when there is one.
20
+ */
21
+ | {
22
+ readonly kind: 'failed';
23
+ readonly detail: string;
24
+ };
25
+ /**
26
+ * Runs commitMessageCommand for one commit (design 14.18): a shell command
27
+ * leading its own process group, ended with the whole group at the time
28
+ * limit or a stop request. Never throws.
29
+ */
30
+ export interface ICommitMessageCommand {
31
+ run(input: CommitMessageCommandInput): Promise<CommitMessageCommandRun>;
32
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -1,5 +1,8 @@
1
- /** The one git question the hook installer needs; it cannot write configuration through it. */
1
+ import type { GitConfigEntry } from './config.js';
2
+ /** The git config questions enmetu asks; it cannot write configuration through it. */
2
3
  export interface IGitConfigReader {
3
4
  /** null when unset at every scope. */
4
5
  configValue(key: string): Promise<string | null>;
6
+ /** Every enmetu.* entry at every scope, in git's reading order; none set: empty (design 14.18). */
7
+ enmetuEntries(): Promise<readonly GitConfigEntry[]>;
5
8
  }
@@ -78,4 +78,9 @@ export interface IGitReader extends IGitConfigReader {
78
78
  mergeBase(left: string, right: string): Promise<string | null>;
79
79
  /** Root-relative paths that differ between two commits, a rename being a delete plus an add, in git's order. */
80
80
  changedPaths(from: string, to: string): Promise<readonly string[]>;
81
+ /**
82
+ * `git diff-tree -p` from a commit to a tree, without colour, external
83
+ * diffs or textconv; the first maxBytes bytes, the rest dropped (design 14.18).
84
+ */
85
+ patch(from: string, toTree: string, maxBytes: number): Promise<Buffer>;
81
86
  }
@@ -0,0 +1,28 @@
1
+ export interface HostMessageInput {
2
+ readonly parent: string;
3
+ readonly tree: string;
4
+ /** The person's command for this change, its message or null; see CommitChange.runCommand. */
5
+ readonly runCommand: () => Promise<string | null>;
6
+ }
7
+ export type HostMessageRun =
8
+ /** The writer returned text; enmetu still checks that it holds a subject. */
9
+ {
10
+ readonly kind: 'wrote';
11
+ readonly text: string;
12
+ }
13
+ /** The writer returned null: commitSubject, nothing logged. */
14
+ | {
15
+ readonly kind: 'declined';
16
+ }
17
+ /** A throw, the time limit, a stop request or a value that is neither text nor null. */
18
+ | {
19
+ readonly kind: 'failed';
20
+ readonly detail: string;
21
+ };
22
+ /**
23
+ * The host program's commit message writer (EnmetuStartOptions.commitMessage)
24
+ * with its time limit, behind one call that never throws (design 14.19).
25
+ */
26
+ export interface IHostMessageWriter {
27
+ write(input: HostMessageInput): Promise<HostMessageRun>;
28
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -17,10 +17,43 @@ export interface EnmetuConfig {
17
17
  readonly commitHooks: boolean;
18
18
  /** How long one repository hook may run before it is a rejection (design 12.7, Decision 83). */
19
19
  readonly hookTimeoutMs: number;
20
+ /**
21
+ * A shell command that writes the commit message; null: commitSubject alone.
22
+ * Read from git config only, never from enmetu.config.json (design 14.18).
23
+ */
24
+ readonly commitMessageCommand: string | null;
25
+ }
26
+ /**
27
+ * One entry of `git config --show-scope --get-regexp ^enmetu\.`, in git's
28
+ * reading order (design 14.18). key is as git prints it: section and variable
29
+ * name lower case. value null: the key is written without a value.
30
+ */
31
+ export interface GitConfigEntry {
32
+ readonly scope: string;
33
+ readonly key: string;
34
+ readonly value: string | null;
20
35
  }
21
- /** What parseConfig read: the config, and the keys it does not know, in file order (design 14.17). */
36
+ /** A setting git config set: the start line names it with its scope (design 14.18). */
37
+ export interface ConfigFromGit {
38
+ /** The setting's name as enmetu.config.json spells it, such as quietMs. */
39
+ readonly key: string;
40
+ readonly scope: string;
41
+ /** The value as the start line shows it; commitMessageCommand shows "set", never the command. */
42
+ readonly shown: string;
43
+ }
44
+ /** What parseConfig read: the config, and what it ignored, in reading order (design 14.17, 14.18). */
22
45
  export interface ParsedConfig {
23
46
  readonly config: EnmetuConfig;
24
47
  /** Ignored; the run command names each once at start. */
25
48
  readonly unknownKeys: readonly string[];
49
+ /** Keys of enmetu.config.json only git config may set (commitMessageCommand); ignored, named at start. */
50
+ readonly gitOnlyKeysInFile: readonly string[];
51
+ /** enmetu.* entries of git config enmetu does not know, once per key and scope; ignored, named at start. */
52
+ readonly unknownGitKeys: readonly GitConfigEntryName[];
53
+ /** Every setting git config set, in the order of the configuration table. */
54
+ readonly fromGit: readonly ConfigFromGit[];
55
+ }
56
+ export interface GitConfigEntryName {
57
+ readonly scope: string;
58
+ readonly key: string;
26
59
  }
@@ -21,7 +21,11 @@ export declare class GitOutputError extends EnmetuError {
21
21
  }
22
22
  export declare class ConfigError extends EnmetuError {
23
23
  readonly key: string | null;
24
- constructor(message: string, code: ConfigErrorCode, key: string | null);
24
+ /** The git config scope the invalid value came from; null: enmetu.config.json (design 14.18). */
25
+ readonly gitScope: string | null;
26
+ constructor(message: string, code: ConfigErrorCode, key: string | null,
27
+ /** The git config scope the invalid value came from; null: enmetu.config.json (design 14.18). */
28
+ gitScope?: string | null);
25
29
  }
26
30
  export declare class PreflightError extends EnmetuError {
27
31
  constructor(message: string, code: PreflightErrorCode, cycleId?: string | undefined);
@@ -29,9 +29,13 @@ export class GitOutputError extends EnmetuError {
29
29
  }
30
30
  export class ConfigError extends EnmetuError {
31
31
  key;
32
- constructor(message, code, key) {
32
+ gitScope;
33
+ constructor(message, code, key,
34
+ /** The git config scope the invalid value came from; null: enmetu.config.json (design 14.18). */
35
+ gitScope = null) {
33
36
  super(message, code);
34
37
  this.key = key;
38
+ this.gitScope = gitScope;
35
39
  this.name = 'ConfigError';
36
40
  }
37
41
  }
@@ -9,6 +9,36 @@ export interface EnmetuStartOptions {
9
9
  readonly log?: ITextSink | undefined;
10
10
  /** The environment git children start from; default the process environment. */
11
11
  readonly env?: Readonly<Record<string, string | undefined>> | undefined;
12
+ /**
13
+ * Writes the message of each commit enmetu makes (design 14.19); without
14
+ * it, the person's commitMessageCommand does, else commitSubject.
15
+ */
16
+ readonly commitMessage?: CommitMessageWriter | undefined;
17
+ }
18
+ /**
19
+ * A host program's commit message writer (design 14.19). It returns the
20
+ * message, its first line the subject (at most 100 characters, not starting
21
+ * with #) and the lines after it the body, or null for commitSubject. It has
22
+ * 10 s; a throw, a timeout, or text without a subject also falls back to
23
+ * commitSubject, with one MESSAGE_WRITER_FAILED line.
24
+ */
25
+ export type CommitMessageWriter = (change: CommitChange) => Promise<string | null>;
26
+ /** One commit enmetu is about to make, as a host's writer sees it (design 14.19). */
27
+ export interface CommitChange {
28
+ /** The repository root. */
29
+ readonly root: string;
30
+ /** The commit's only parent. */
31
+ readonly parent: string;
32
+ /** The commit's tree; `git diff-tree -r <parent> <tree>` lists the change. */
33
+ readonly tree: string;
34
+ /**
35
+ * The person's commitMessageCommand on this change: its message, or null
36
+ * when none is set or it failed (enmetu logs the failure). With a writer,
37
+ * the command runs only through this call, at most once per commit.
38
+ */
39
+ runCommand(): Promise<string | null>;
40
+ /** Aborted when the writer's time is up or enmetu stops. */
41
+ readonly signal: AbortSignal;
12
42
  }
13
43
  export interface EnmetuInstance {
14
44
  /**
@@ -1 +1 @@
1
- export type LogCode = 'STARTED' | 'STOPPING' | 'STOPPED' | 'LOCK_STALE_TAKEN' | 'RECOVERED' | 'HOOK_INSTALLED' | 'HOOK_UPDATED' | 'HOOK_SKIPPED' | 'HOOK_FAILED' | 'WATCHER_FAILED' | 'COMMITTED' | 'STAGE_PARTIAL' | 'EXCLUDED' | 'PULLED' | 'PUSHED' | 'PUSH_REJECTED' | 'PUSH_FAILED' | 'OFFLINE' | 'CONFLICT' | 'UNPUSHED' | 'PAUSED' | 'BUSY' | 'RESUMED' | 'QUARANTINE_KEPT' | 'HALTED' | 'CYCLE_FAILED' | 'NOT_ELIGIBLE' | 'LOCKED' | 'CONFIG_INVALID' | 'CONFIG_KEY_IGNORED' | 'STATE_WRITE_FAILED' | 'WAITING' | 'SELECTION_WAIT_ENDED' | 'HOOK_REJECTED' | 'COMMIT_DISCARDED' | 'INDEX_REPAIRED' | 'QUARANTINE_UPSTREAM_CHANGED' | 'REBASE_NOT_STARTED' | 'STAGED_RESET' | 'STAGED_REVERT' | 'REBASE_BRANCH_MOVED' | 'COMMIT_ORPHANED';
1
+ export type LogCode = 'STARTED' | 'STOPPING' | 'STOPPED' | 'LOCK_STALE_TAKEN' | 'RECOVERED' | 'HOOK_INSTALLED' | 'HOOK_UPDATED' | 'HOOK_SKIPPED' | 'HOOK_FAILED' | 'WATCHER_FAILED' | 'COMMITTED' | 'STAGE_PARTIAL' | 'EXCLUDED' | 'PULLED' | 'PUSHED' | 'PUSH_REJECTED' | 'PUSH_FAILED' | 'OFFLINE' | 'CONFLICT' | 'UNPUSHED' | 'PAUSED' | 'BUSY' | 'RESUMED' | 'QUARANTINE_KEPT' | 'HALTED' | 'CYCLE_FAILED' | 'NOT_ELIGIBLE' | 'LOCKED' | 'CONFIG_INVALID' | 'CONFIG_KEY_IGNORED' | 'CONFIG_FROM_GIT' | 'MESSAGE_COMMAND_FAILED' | 'MESSAGE_WRITER_FAILED' | 'STATE_WRITE_FAILED' | 'WAITING' | 'SELECTION_WAIT_ENDED' | 'HOOK_REJECTED' | 'COMMIT_DISCARDED' | 'INDEX_REPAIRED' | 'QUARANTINE_UPSTREAM_CHANGED' | 'REBASE_NOT_STARTED' | 'STAGED_RESET' | 'STAGED_REVERT' | 'REBASE_BRANCH_MOVED' | 'COMMIT_ORPHANED';
@@ -11,6 +11,10 @@ export interface Runtime {
11
11
  /** Absolute path of the CLI script (Node resolves argv[1]), recorded in the hook. */
12
12
  readonly cliPath: string;
13
13
  readonly version: string;
14
+ /** process.platform: 'linux', 'darwin', 'win32', ... */
15
+ readonly platform: string;
16
+ /** The JavaScript runtime enmetu runs on: 'bun' when process.versions.bun is set. */
17
+ readonly engine: 'node' | 'bun';
14
18
  readonly stdout: ITextSink;
15
19
  readonly stderr: ITextSink;
16
20
  }
@@ -1,6 +1,33 @@
1
+ /** The longest subject enmetu commits with, from commitSubject or commitMessageCommand. */
2
+ export declare const MAX_SUBJECT_CHARACTERS = 100;
1
3
  /**
2
4
  * git's boolean config reading for commit.gpgSign: true, yes, on, 1 (any
3
5
  * case) and '' (a key written without a value) are true; null (unset) and
4
6
  * everything else are false.
5
7
  */
6
8
  export declare function gitBoolean(value: string | null): boolean;
9
+ /**
10
+ * One line of 1 to MAX_SUBJECT_CHARACTERS characters, not blank and not
11
+ * starting with #. git aborts a commit whose message is empty after cleanup,
12
+ * and a person's commit.cleanup=strip removes a line starting with #.
13
+ */
14
+ export declare function isCommitSubject(value: string): boolean;
15
+ export type ParsedMessage = {
16
+ readonly kind: 'message';
17
+ readonly message: string;
18
+ }
19
+ /** What the text holds instead, read after "printed" or "returned", such as "no subject". */
20
+ | {
21
+ readonly kind: 'refused';
22
+ readonly reason: string;
23
+ };
24
+ /**
25
+ * A message writer's text as a commit message: commitMessageCommand's stdout
26
+ * (design 14.18) or a host program's return value (design 14.19). Line
27
+ * endings become \n, every line loses its trailing whitespace, the subject
28
+ * its leading whitespace too; the first non-blank line is the subject, the
29
+ * lines after it, without the blank lines around them, the body. Refused: no
30
+ * subject, a NUL byte (git refuses one in a message), or a subject
31
+ * isCommitSubject refuses.
32
+ */
33
+ export declare function messageFromText(text: string): ParsedMessage;