@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.
- package/README.md +53 -5
- package/dist/adapters/git/gitFacade.d.ts +8 -0
- package/dist/adapters/git/gitFacade.js +34 -1
- package/dist/adapters/git/gitProcessAdapter.js +1 -48
- package/dist/adapters/host/hostMessageWriterAdapter.d.ts +23 -0
- package/dist/adapters/host/hostMessageWriterAdapter.js +76 -0
- package/dist/adapters/process/processGroup.d.ts +18 -0
- package/dist/adapters/process/processGroup.js +52 -0
- package/dist/adapters/process/shellMessageCommandAdapter.d.ts +24 -0
- package/dist/adapters/process/shellMessageCommandAdapter.js +102 -0
- package/dist/adapters/watch/watcherFor.d.ts +22 -0
- package/dist/adapters/watch/watcherFor.js +29 -0
- package/dist/cli/commands/hookCommand.d.ts +2 -1
- package/dist/cli/commands/hookCommand.js +14 -8
- package/dist/cli/commands/runCommand.d.ts +10 -3
- package/dist/cli/commands/runCommand.js +45 -9
- package/dist/cli.js +2 -0
- package/dist/commit/commitLocalChangesController.d.ts +22 -1
- package/dist/commit/commitLocalChangesController.js +113 -4
- package/dist/contracts/ICommitMessageCommand.d.ts +32 -0
- package/dist/contracts/ICommitMessageCommand.js +1 -0
- package/dist/contracts/IGitConfigReader.d.ts +4 -1
- package/dist/contracts/IGitReader.d.ts +5 -0
- package/dist/contracts/IHostMessageWriter.d.ts +28 -0
- package/dist/contracts/IHostMessageWriter.js +1 -0
- package/dist/contracts/config.d.ts +34 -1
- package/dist/contracts/errors.d.ts +5 -1
- package/dist/contracts/errors.js +5 -1
- package/dist/contracts/library.d.ts +30 -0
- package/dist/contracts/logCodes.d.ts +1 -1
- package/dist/contracts/runtime.d.ts +4 -0
- package/dist/core/commit/commitMessage.d.ts +27 -0
- package/dist/core/commit/commitMessage.js +61 -0
- package/dist/core/config/parseConfig.d.ts +7 -4
- package/dist/core/config/parseConfig.js +172 -26
- package/dist/core/git/porcelain.d.ts +7 -0
- package/dist/core/git/porcelain.js +24 -0
- package/dist/enmetuInstances.d.ts +7 -4
- package/dist/enmetuInstances.js +52 -13
- package/dist/index.d.ts +1 -1
- package/dist/index.js +2 -0
- package/dist/library/enmetuLibrary.d.ts +2 -1
- package/dist/library/enmetuLibrary.js +5 -2
- package/dist/quarantine/pathQuarantine.d.ts +10 -0
- package/dist/quarantine/pathQuarantine.js +44 -2
- package/package.json +1 -1
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
const GIT_TRUE = ['true', 'yes', 'on', '1', ''];
|
|
2
|
+
/** The longest subject enmetu commits with, from commitSubject or commitMessageCommand. */
|
|
3
|
+
export const MAX_SUBJECT_CHARACTERS = 100;
|
|
2
4
|
/**
|
|
3
5
|
* git's boolean config reading for commit.gpgSign: true, yes, on, 1 (any
|
|
4
6
|
* case) and '' (a key written without a value) are true; null (unset) and
|
|
@@ -10,3 +12,62 @@ export function gitBoolean(value) {
|
|
|
10
12
|
}
|
|
11
13
|
return GIT_TRUE.includes(value.toLowerCase());
|
|
12
14
|
}
|
|
15
|
+
/**
|
|
16
|
+
* One line of 1 to MAX_SUBJECT_CHARACTERS characters, not blank and not
|
|
17
|
+
* starting with #. git aborts a commit whose message is empty after cleanup,
|
|
18
|
+
* and a person's commit.cleanup=strip removes a line starting with #.
|
|
19
|
+
*/
|
|
20
|
+
export function isCommitSubject(value) {
|
|
21
|
+
const characters = Array.from(value).length;
|
|
22
|
+
return (characters >= 1 &&
|
|
23
|
+
characters <= MAX_SUBJECT_CHARACTERS &&
|
|
24
|
+
!/[\r\n]/.test(value) &&
|
|
25
|
+
!value.startsWith('#') &&
|
|
26
|
+
value.trim() !== '');
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* A message writer's text as a commit message: commitMessageCommand's stdout
|
|
30
|
+
* (design 14.18) or a host program's return value (design 14.19). Line
|
|
31
|
+
* endings become \n, every line loses its trailing whitespace, the subject
|
|
32
|
+
* its leading whitespace too; the first non-blank line is the subject, the
|
|
33
|
+
* lines after it, without the blank lines around them, the body. Refused: no
|
|
34
|
+
* subject, a NUL byte (git refuses one in a message), or a subject
|
|
35
|
+
* isCommitSubject refuses.
|
|
36
|
+
*/
|
|
37
|
+
export function messageFromText(text) {
|
|
38
|
+
if (text.includes('\0')) {
|
|
39
|
+
return { kind: 'refused', reason: 'a NUL byte' };
|
|
40
|
+
}
|
|
41
|
+
const lines = withoutOuterBlankLines(text
|
|
42
|
+
.replace(/\r\n?/g, '\n')
|
|
43
|
+
.split('\n')
|
|
44
|
+
.map((line) => line.trimEnd()));
|
|
45
|
+
const subject = (lines[0] ?? '').trim();
|
|
46
|
+
if (subject === '') {
|
|
47
|
+
return { kind: 'refused', reason: 'no subject' };
|
|
48
|
+
}
|
|
49
|
+
if (!isCommitSubject(subject)) {
|
|
50
|
+
return {
|
|
51
|
+
kind: 'refused',
|
|
52
|
+
reason: subject.startsWith('#')
|
|
53
|
+
? 'a subject starting with #'
|
|
54
|
+
: `a subject of ${Array.from(subject).length} characters, more than ${MAX_SUBJECT_CHARACTERS}`,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
const body = withoutOuterBlankLines(lines.slice(1)).join('\n');
|
|
58
|
+
return {
|
|
59
|
+
kind: 'message',
|
|
60
|
+
message: body === '' ? `${subject}\n` : `${subject}\n\n${body}\n`,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
function withoutOuterBlankLines(lines) {
|
|
64
|
+
let start = 0;
|
|
65
|
+
let end = lines.length;
|
|
66
|
+
while (start < end && lines[start] === '') {
|
|
67
|
+
start += 1;
|
|
68
|
+
}
|
|
69
|
+
while (end > start && lines[end - 1] === '') {
|
|
70
|
+
end -= 1;
|
|
71
|
+
}
|
|
72
|
+
return lines.slice(start, end);
|
|
73
|
+
}
|
|
@@ -1,8 +1,11 @@
|
|
|
1
|
-
import type { EnmetuConfig, ParsedConfig } from '../../contracts/config.js';
|
|
1
|
+
import type { EnmetuConfig, GitConfigEntry, ParsedConfig } from '../../contracts/config.js';
|
|
2
2
|
export declare const CONFIG_FILE_NAME = "enmetu.config.json";
|
|
3
3
|
export declare const DEFAULT_CONFIG: EnmetuConfig;
|
|
4
4
|
/**
|
|
5
|
-
* text null: the file is absent
|
|
6
|
-
*
|
|
5
|
+
* text null: the file is absent. gitEntries: every enmetu.* entry of git
|
|
6
|
+
* config, in git's reading order; a setting there replaces the file's and is
|
|
7
|
+
* checked the same way (design 14.18). Throws ConfigError for text that does
|
|
8
|
+
* not parse and for an invalid value of a known key, with gitScope set when
|
|
9
|
+
* git config gave the value.
|
|
7
10
|
*/
|
|
8
|
-
export declare function parseConfig(text: string | null): ParsedConfig;
|
|
11
|
+
export declare function parseConfig(text: string | null, gitEntries?: readonly GitConfigEntry[]): ParsedConfig;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { ConfigError } from '../../contracts/errors.js';
|
|
2
|
+
import { isCommitSubject, MAX_SUBJECT_CHARACTERS, } from '../commit/commitMessage.js';
|
|
2
3
|
import { errorMessage } from '../errors/errorMessage.js';
|
|
3
4
|
import { MAX_STATE_HISTORY } from '../state/stateRecordStep.js';
|
|
4
5
|
export const CONFIG_FILE_NAME = 'enmetu.config.json';
|
|
@@ -14,6 +15,7 @@ export const DEFAULT_CONFIG = {
|
|
|
14
15
|
partialStagingWaitMs: 300000,
|
|
15
16
|
commitHooks: true,
|
|
16
17
|
hookTimeoutMs: 600000,
|
|
18
|
+
commitMessageCommand: null,
|
|
17
19
|
};
|
|
18
20
|
const KNOWN_KEYS = [
|
|
19
21
|
'quietMs',
|
|
@@ -27,29 +29,93 @@ const KNOWN_KEYS = [
|
|
|
27
29
|
'partialStagingWaitMs',
|
|
28
30
|
'commitHooks',
|
|
29
31
|
'hookTimeoutMs',
|
|
32
|
+
'commitMessageCommand',
|
|
30
33
|
];
|
|
31
|
-
|
|
34
|
+
/**
|
|
35
|
+
* Set by git config alone: a repository's file must never make a person's
|
|
36
|
+
* machine run a program (design 14.18).
|
|
37
|
+
*/
|
|
38
|
+
const GIT_ONLY_KEYS = ['commitMessageCommand'];
|
|
39
|
+
/** How a git config value is read; every other key is an integer (design 14.18). */
|
|
40
|
+
const BOOLEAN_KEYS = ['installHook', 'commitHooks'];
|
|
41
|
+
const TEXT_KEYS = [
|
|
42
|
+
'commitSubject',
|
|
43
|
+
'commitMessageCommand',
|
|
44
|
+
];
|
|
45
|
+
const LIST_KEYS = ['skipJsonCheck'];
|
|
46
|
+
/** git's spellings of true and false; a key written without a value is true. */
|
|
47
|
+
const GIT_TRUE_WORDS = ['true', 'yes', 'on', '1'];
|
|
48
|
+
const GIT_FALSE_WORDS = ['false', 'no', 'off', '0', ''];
|
|
49
|
+
const GIT_SECTION = 'enmetu.';
|
|
32
50
|
/** The range of hookTimeoutMs: one second to one day (design 12.7). */
|
|
33
51
|
const MIN_HOOK_TIMEOUT_MS = 1000;
|
|
34
52
|
const MAX_HOOK_TIMEOUT_MS = 86400000;
|
|
35
53
|
/**
|
|
36
|
-
* text null: the file is absent
|
|
37
|
-
*
|
|
54
|
+
* text null: the file is absent. gitEntries: every enmetu.* entry of git
|
|
55
|
+
* config, in git's reading order; a setting there replaces the file's and is
|
|
56
|
+
* checked the same way (design 14.18). Throws ConfigError for text that does
|
|
57
|
+
* not parse and for an invalid value of a known key, with gitScope set when
|
|
58
|
+
* git config gave the value.
|
|
38
59
|
*/
|
|
39
|
-
export function parseConfig(text) {
|
|
40
|
-
|
|
41
|
-
|
|
60
|
+
export function parseConfig(text, gitEntries = []) {
|
|
61
|
+
const file = text === null ? {} : fileSettings(text);
|
|
62
|
+
const fileKeys = Object.keys(file);
|
|
63
|
+
// Ignored rather than refused, so a key only a newer enmetu knows never
|
|
64
|
+
// stops an older one; the caller names each, so a typo is not silent
|
|
65
|
+
// (design 14.17).
|
|
66
|
+
const unknownKeys = fileKeys.filter((key) => configKey(key) === null);
|
|
67
|
+
const gitOnlyKeysInFile = fileKeys.filter((key) => isGitOnly(key));
|
|
68
|
+
const git = gitSettings(gitEntries);
|
|
69
|
+
const settings = {};
|
|
70
|
+
for (const key of fileKeys) {
|
|
71
|
+
if (configKey(key) !== null && !isGitOnly(key)) {
|
|
72
|
+
settings[key] = file[key];
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
for (const setting of git.settings) {
|
|
76
|
+
settings[setting.key] = setting.value;
|
|
42
77
|
}
|
|
78
|
+
const config = configWithScope(settings, git.settings);
|
|
79
|
+
return {
|
|
80
|
+
config,
|
|
81
|
+
unknownKeys,
|
|
82
|
+
gitOnlyKeysInFile,
|
|
83
|
+
unknownGitKeys: git.unknown,
|
|
84
|
+
fromGit: git.settings.map((setting) => ({
|
|
85
|
+
key: setting.key,
|
|
86
|
+
scope: setting.scope,
|
|
87
|
+
shown: shownValue(setting.key, config),
|
|
88
|
+
})),
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
function fileSettings(text) {
|
|
43
92
|
const parsed = parseJson(text);
|
|
44
93
|
if (!isRecord(parsed)) {
|
|
45
94
|
throw new ConfigError('the top level must be a JSON object', 'CONFIG_INVALID_VALUE', null);
|
|
46
95
|
}
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
96
|
+
return parsed;
|
|
97
|
+
}
|
|
98
|
+
/** The checks of every key, with the scope of a git config value named in the error. */
|
|
99
|
+
function configWithScope(settings, git) {
|
|
100
|
+
try {
|
|
101
|
+
return configOf(settings);
|
|
102
|
+
}
|
|
103
|
+
catch (error) {
|
|
104
|
+
if (!(error instanceof ConfigError)) {
|
|
105
|
+
throw error;
|
|
106
|
+
}
|
|
107
|
+
const { key } = error;
|
|
108
|
+
const scope = git.find((setting) => setting.key === key)?.scope;
|
|
109
|
+
if (scope === undefined) {
|
|
110
|
+
throw error;
|
|
111
|
+
}
|
|
112
|
+
// Only invalid() names a key, and it always throws CONFIG_INVALID_VALUE.
|
|
113
|
+
throw new ConfigError(error.message, 'CONFIG_INVALID_VALUE', key, scope);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
function configOf(parsed) {
|
|
51
117
|
const quietMs = readInteger(parsed, 'quietMs', 100, 600000);
|
|
52
|
-
|
|
118
|
+
return {
|
|
53
119
|
quietMs,
|
|
54
120
|
maxDelayMs: readInteger(parsed, 'maxDelayMs', quietMs, 3600000),
|
|
55
121
|
pollMs: readInteger(parsed, 'pollMs', 100, 3600000),
|
|
@@ -61,8 +127,89 @@ export function parseConfig(text) {
|
|
|
61
127
|
partialStagingWaitMs: readPartialStagingWaitMs(parsed),
|
|
62
128
|
commitHooks: readBoolean(parsed, 'commitHooks'),
|
|
63
129
|
hookTimeoutMs: readHookTimeoutMs(parsed),
|
|
130
|
+
commitMessageCommand: readCommitMessageCommand(parsed),
|
|
64
131
|
};
|
|
65
|
-
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* The enmetu.* entries of git config as settings, in KNOWN_KEYS order, and
|
|
135
|
+
* the entries enmetu does not know (a subsection is unknown too). git's rule
|
|
136
|
+
* decides between scopes: the last value read wins, so a local value beats a
|
|
137
|
+
* global one; a list is every value of that last scope, so a local list
|
|
138
|
+
* replaces a global one as a whole.
|
|
139
|
+
*/
|
|
140
|
+
function gitSettings(entries) {
|
|
141
|
+
const unknown = [];
|
|
142
|
+
const byKey = new Map();
|
|
143
|
+
for (const entry of entries) {
|
|
144
|
+
const key = gitConfigKey(entry.key);
|
|
145
|
+
if (key === null) {
|
|
146
|
+
if (!unknown.some((name) => name.key === entry.key && name.scope === entry.scope)) {
|
|
147
|
+
unknown.push({ scope: entry.scope, key: entry.key });
|
|
148
|
+
}
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
byKey.set(key, [...(byKey.get(key) ?? []), entry]);
|
|
152
|
+
}
|
|
153
|
+
const settings = [];
|
|
154
|
+
for (const key of KNOWN_KEYS) {
|
|
155
|
+
const set = byKey.get(key) ?? [];
|
|
156
|
+
const last = set[set.length - 1];
|
|
157
|
+
if (last === undefined) {
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
const value = LIST_KEYS.includes(key)
|
|
161
|
+
? set
|
|
162
|
+
.filter((entry) => entry.scope === last.scope)
|
|
163
|
+
.map((entry) => entry.value)
|
|
164
|
+
: gitValue(key, last.value);
|
|
165
|
+
settings.push({ key, scope: last.scope, value });
|
|
166
|
+
}
|
|
167
|
+
return { settings, unknown };
|
|
168
|
+
}
|
|
169
|
+
/** enmetu.quietms (git prints the variable name lower case) to quietMs; null for anything else. */
|
|
170
|
+
function gitConfigKey(gitKey) {
|
|
171
|
+
if (!gitKey.toLowerCase().startsWith(GIT_SECTION)) {
|
|
172
|
+
return null;
|
|
173
|
+
}
|
|
174
|
+
const name = gitKey.slice(GIT_SECTION.length).toLowerCase();
|
|
175
|
+
return KNOWN_KEYS.find((key) => key.toLowerCase() === name) ?? null;
|
|
176
|
+
}
|
|
177
|
+
/**
|
|
178
|
+
* A git config value as the JSON value it stands for, so the file's checks
|
|
179
|
+
* apply unchanged: digits become an integer, git's boolean words a boolean;
|
|
180
|
+
* anything else stays text, which the check of a number or boolean refuses.
|
|
181
|
+
*/
|
|
182
|
+
function gitValue(key, value) {
|
|
183
|
+
if (TEXT_KEYS.includes(key)) {
|
|
184
|
+
return value;
|
|
185
|
+
}
|
|
186
|
+
if (BOOLEAN_KEYS.includes(key)) {
|
|
187
|
+
if (value === null) {
|
|
188
|
+
return true;
|
|
189
|
+
}
|
|
190
|
+
const word = value.toLowerCase();
|
|
191
|
+
if (GIT_TRUE_WORDS.includes(word)) {
|
|
192
|
+
return true;
|
|
193
|
+
}
|
|
194
|
+
return GIT_FALSE_WORDS.includes(word) ? false : value;
|
|
195
|
+
}
|
|
196
|
+
return value !== null && /^-?[0-9]+$/.test(value) ? Number(value) : value;
|
|
197
|
+
}
|
|
198
|
+
/** The start line shows a setting's value; a command may carry a token, so it shows only that it is set. */
|
|
199
|
+
function shownValue(key, config) {
|
|
200
|
+
if (key === 'commitMessageCommand') {
|
|
201
|
+
return 'set';
|
|
202
|
+
}
|
|
203
|
+
const value = config[key];
|
|
204
|
+
return typeof value === 'number' || typeof value === 'boolean'
|
|
205
|
+
? String(value)
|
|
206
|
+
: JSON.stringify(value);
|
|
207
|
+
}
|
|
208
|
+
function configKey(key) {
|
|
209
|
+
return KNOWN_KEYS.find((known) => known === key) ?? null;
|
|
210
|
+
}
|
|
211
|
+
function isGitOnly(key) {
|
|
212
|
+
return GIT_ONLY_KEYS.some((gitOnly) => gitOnly === key);
|
|
66
213
|
}
|
|
67
214
|
function parseJson(text) {
|
|
68
215
|
try {
|
|
@@ -103,20 +250,8 @@ function readCommitSubject(parsed) {
|
|
|
103
250
|
if (value === undefined) {
|
|
104
251
|
return DEFAULT_CONFIG.commitSubject;
|
|
105
252
|
}
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
throw invalid('commitSubject', rule);
|
|
109
|
-
}
|
|
110
|
-
const characters = Array.from(value).length;
|
|
111
|
-
// git aborts a commit whose message is empty after cleanup, and a person's
|
|
112
|
-
// commit.cleanup=strip removes a line starting with #; either would make
|
|
113
|
-
// every daemon commit fail.
|
|
114
|
-
if (characters < 1 ||
|
|
115
|
-
characters > MAX_SUBJECT_CHARACTERS ||
|
|
116
|
-
/[\r\n]/.test(value) ||
|
|
117
|
-
value.startsWith('#') ||
|
|
118
|
-
value.trim() === '') {
|
|
119
|
-
throw invalid('commitSubject', rule);
|
|
253
|
+
if (typeof value !== 'string' || !isCommitSubject(value)) {
|
|
254
|
+
throw invalid('commitSubject', `must be one line of 1 to ${MAX_SUBJECT_CHARACTERS} characters, not blank and not starting with #`);
|
|
120
255
|
}
|
|
121
256
|
return value;
|
|
122
257
|
}
|
|
@@ -174,3 +309,14 @@ function readPartialStagingWaitMs(parsed) {
|
|
|
174
309
|
function readHookTimeoutMs(parsed) {
|
|
175
310
|
return readInteger(parsed, 'hookTimeoutMs', MIN_HOOK_TIMEOUT_MS, MAX_HOOK_TIMEOUT_MS);
|
|
176
311
|
}
|
|
312
|
+
/** commitMessageCommand: text that is not blank; only git config reaches here (design 14.18). */
|
|
313
|
+
function readCommitMessageCommand(parsed) {
|
|
314
|
+
const value = parsed.commitMessageCommand;
|
|
315
|
+
if (value === undefined) {
|
|
316
|
+
return DEFAULT_CONFIG.commitMessageCommand;
|
|
317
|
+
}
|
|
318
|
+
if (typeof value !== 'string' || value.trim() === '') {
|
|
319
|
+
throw invalid('commitMessageCommand', 'must be a command, not blank');
|
|
320
|
+
}
|
|
321
|
+
return value;
|
|
322
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { GitConfigEntry } from '../../contracts/config.js';
|
|
1
2
|
import type { Divergence, StatusEntry, Upstream } from '../../contracts/gitTypes.js';
|
|
2
3
|
import type { GitVersion } from '../../contracts/IRepositoryLocator.js';
|
|
3
4
|
/**
|
|
@@ -48,5 +49,11 @@ export declare function parseBatch(stdout: Buffer): readonly BatchBlob[];
|
|
|
48
49
|
export declare function parseUpstream(stdout: string, branchRef: string): Upstream | null;
|
|
49
50
|
/** The three absolute paths of rev-parse; null unless exactly three absolute lines (a path with a newline). */
|
|
50
51
|
export declare function parseRevParsePaths(stdout: string): RevParsePaths | null;
|
|
52
|
+
/**
|
|
53
|
+
* `git config -z --show-scope --get-regexp` entries: scope NUL, then the key,
|
|
54
|
+
* a newline and the value, NUL; a key written without a value has no newline
|
|
55
|
+
* (measured with git 2.52.0, design 14.18).
|
|
56
|
+
*/
|
|
57
|
+
export declare function parseConfigEntries(stdout: Buffer): readonly GitConfigEntry[];
|
|
51
58
|
/** true when `git push --porcelain` output carries a ref line starting with `!`. */
|
|
52
59
|
export declare function hasRejectedRef(stdout: string): boolean;
|
|
@@ -179,6 +179,30 @@ export function parseRevParsePaths(stdout) {
|
|
|
179
179
|
}
|
|
180
180
|
return { root, gitDir, commonDir };
|
|
181
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;
|
|
205
|
+
}
|
|
182
206
|
/** true when `git push --porcelain` output carries a ref line starting with `!`. */
|
|
183
207
|
export function hasRejectedRef(stdout) {
|
|
184
208
|
return stdout.split('\n').some((line) => line.startsWith('!'));
|
|
@@ -7,6 +7,7 @@ import type { IDaemon } from './contracts/IDaemon.js';
|
|
|
7
7
|
import type { IHookInstaller } from './contracts/IHookInstaller.js';
|
|
8
8
|
import type { IProcessHost } from './contracts/IProcessHost.js';
|
|
9
9
|
import type { IRepositoryLocator, RepositoryPaths } from './contracts/IRepositoryLocator.js';
|
|
10
|
+
import type { CommitMessageWriter } from './contracts/library.js';
|
|
10
11
|
import type { Runtime } from './contracts/runtime.js';
|
|
11
12
|
import type { ReadStateDeps } from './library/readStateController.js';
|
|
12
13
|
import { type RecoveryDeps } from './recovery/recoveryController.js';
|
|
@@ -25,9 +26,10 @@ export declare function createRecoveryDeps(paths: RepositoryPaths, config: Enmet
|
|
|
25
26
|
* their controllers. Recovery, preflight, hook installer and cycle share one
|
|
26
27
|
* set of repository parts, so one halt store guards every writer. processHost
|
|
27
28
|
* defaults to the operating system's signals; the library passes
|
|
28
|
-
* RequestedStopProcessHost so a host program's process is left alone
|
|
29
|
+
* RequestedStopProcessHost so a host program's process is left alone, and the
|
|
30
|
+
* host's commitMessage writer when it has one (design 14.19).
|
|
29
31
|
*/
|
|
30
|
-
export declare function createDaemon(paths: RepositoryPaths, config: EnmetuConfig, runtime: Runtime, processHost?: IProcessHost | undefined): IDaemon;
|
|
32
|
+
export declare function createDaemon(paths: RepositoryPaths, config: EnmetuConfig, runtime: Runtime, processHost?: IProcessHost | undefined, commitMessage?: CommitMessageWriter | undefined): IDaemon;
|
|
31
33
|
/**
|
|
32
34
|
* The daemon's hook installer for one repository (design 13.1): it records
|
|
33
35
|
* Runtime.execPath, resolved through the PATH enmetu was started with when it
|
|
@@ -58,8 +60,9 @@ export interface EmbeddedRun {
|
|
|
58
60
|
/**
|
|
59
61
|
* runCommand's deps with one process host shared by the daemon and its lock:
|
|
60
62
|
* the lock still records this process's pid and hostname, while stops come
|
|
61
|
-
* from requestStop() instead of the host program's signals.
|
|
63
|
+
* from requestStop() instead of the host program's signals. commitMessage is
|
|
64
|
+
* the host's writer, when it passed one (design 14.19).
|
|
62
65
|
*/
|
|
63
|
-
export declare function createEmbeddedRunDeps(runtime: Runtime): EmbeddedRun;
|
|
66
|
+
export declare function createEmbeddedRunDeps(runtime: Runtime, commitMessage?: CommitMessageWriter | undefined): EmbeddedRun;
|
|
64
67
|
/** The library's state read: the state file and the instance lock, nothing else (design 12.5). */
|
|
65
68
|
export declare function createReadStateDeps(runtime: Runtime): ReadStateDeps;
|
package/dist/enmetuInstances.js
CHANGED
|
@@ -5,10 +5,12 @@ import { NodeFileSystemAdapter } from './adapters/fs/nodeFileSystemAdapter.js';
|
|
|
5
5
|
import { GitFacade } from './adapters/git/gitFacade.js';
|
|
6
6
|
import { GitLocatorFacade } from './adapters/git/gitLocatorFacade.js';
|
|
7
7
|
import { GitProcessAdapter } from './adapters/git/gitProcessAdapter.js';
|
|
8
|
+
import { HostMessageWriterAdapter } from './adapters/host/hostMessageWriterAdapter.js';
|
|
8
9
|
import { NullLogger } from './adapters/log/nullLogger.js';
|
|
9
10
|
import { StderrLoggerAdapter } from './adapters/log/stderrLoggerAdapter.js';
|
|
10
11
|
import { NodeProcessAdapter } from './adapters/process/nodeProcessAdapter.js';
|
|
11
|
-
import {
|
|
12
|
+
import { ShellMessageCommandAdapter } from './adapters/process/shellMessageCommandAdapter.js';
|
|
13
|
+
import { watcherFor } from './adapters/watch/watcherFor.js';
|
|
12
14
|
import { commitLocalChangesController, } from './commit/commitLocalChangesController.js';
|
|
13
15
|
import { GitIndexLock } from './commit/gitIndexLock.js';
|
|
14
16
|
import { DEFAULT_CONFIG } from './core/config/parseConfig.js';
|
|
@@ -73,9 +75,10 @@ export function createRecoveryDeps(paths, config, runtime) {
|
|
|
73
75
|
* their controllers. Recovery, preflight, hook installer and cycle share one
|
|
74
76
|
* set of repository parts, so one halt store guards every writer. processHost
|
|
75
77
|
* defaults to the operating system's signals; the library passes
|
|
76
|
-
* RequestedStopProcessHost so a host program's process is left alone
|
|
78
|
+
* RequestedStopProcessHost so a host program's process is left alone, and the
|
|
79
|
+
* host's commitMessage writer when it has one (design 14.19).
|
|
77
80
|
*/
|
|
78
|
-
export function createDaemon(paths, config, runtime, processHost) {
|
|
81
|
+
export function createDaemon(paths, config, runtime, processHost, commitMessage) {
|
|
79
82
|
const parts = guardedRepositoryParts(paths, config.gitTimeoutMs, runtime);
|
|
80
83
|
const host = processHost ?? new NodeProcessAdapter();
|
|
81
84
|
// One recorder for the daemon's lifecycle events, the Decorator's cycle
|
|
@@ -93,7 +96,7 @@ export function createDaemon(paths, config, runtime, processHost) {
|
|
|
93
96
|
// Aborted by the first stop request, so a running repository hook ends at
|
|
94
97
|
// once instead of holding the stop for up to hookTimeoutMs (Decision 92).
|
|
95
98
|
const hookStop = new AbortController();
|
|
96
|
-
const cycleDeps = syncCycleDepsOf(parts, commitOf(parts, paths, config, runtime, hookStop.signal), recorder);
|
|
99
|
+
const cycleDeps = syncCycleDepsOf(parts, commitOf(parts, paths, config, runtime, hookStop.signal, commitMessage), recorder);
|
|
97
100
|
const cycle = new StateRecordingSyncCycle({ run: (input) => syncCycleController(input, cycleDeps) }, state, parts.git);
|
|
98
101
|
const hookInstaller = hookInstallerOf(parts, paths, config, runtime);
|
|
99
102
|
const scheduler = new HookAbortingSyncScheduler(new SyncScheduler(new SyncCycleRunner(cycle, parts.logger), parts.clock, {
|
|
@@ -101,7 +104,7 @@ export function createDaemon(paths, config, runtime, processHost) {
|
|
|
101
104
|
maxDelayMs: config.maxDelayMs,
|
|
102
105
|
pollMs: config.pollMs,
|
|
103
106
|
}), hookStop);
|
|
104
|
-
return new Daemon({ root: paths.root, quietMs: config.quietMs, pollMs: config.pollMs }, lockFor(paths, parts.fs, host, parts.clock), parts.halt, recovery, preflight, hookInstaller,
|
|
107
|
+
return new Daemon({ root: paths.root, quietMs: config.quietMs, pollMs: config.pollMs }, lockFor(paths, parts.fs, host, parts.clock), parts.halt, recovery, preflight, hookInstaller, watcherFor(paths.root, runtime), scheduler, host, parts.logger, state, parts.clock);
|
|
105
108
|
}
|
|
106
109
|
/**
|
|
107
110
|
* The daemon's hook installer for one repository (design 13.1): it records
|
|
@@ -118,13 +121,19 @@ function hookInstallerOf(parts, paths, config, runtime) {
|
|
|
118
121
|
: new NullHookInstaller();
|
|
119
122
|
}
|
|
120
123
|
export function createRunDeps(runtime) {
|
|
124
|
+
const fs = new NodeFileSystemAdapter();
|
|
121
125
|
return {
|
|
122
126
|
locator: createLocator(runtime),
|
|
123
|
-
fs
|
|
127
|
+
fs,
|
|
128
|
+
readGitConfig: gitConfigReaderFor(runtime, fs),
|
|
124
129
|
logger: new StderrLoggerAdapter(runtime.stderr, new SystemClockAdapter()),
|
|
125
130
|
createDaemon: (paths, config) => createDaemon(paths, config, runtime),
|
|
126
131
|
};
|
|
127
132
|
}
|
|
133
|
+
/** The run command's git config read, before the config and its git timeout are known (design 14.18). */
|
|
134
|
+
function gitConfigReaderFor(runtime, fs) {
|
|
135
|
+
return (paths) => facadeFor(paths, UNCONFIGURED_GIT_TIMEOUT_MS, runtime, fs).enmetuEntries();
|
|
136
|
+
}
|
|
128
137
|
/**
|
|
129
138
|
* Status reads only; its quarantine still sits on the guarded writer, so even
|
|
130
139
|
* a mistaken write from status would honour the halt.
|
|
@@ -251,15 +260,27 @@ function lockFor(paths, fs, processHost, clock) {
|
|
|
251
260
|
}
|
|
252
261
|
/** The commit index of design 12.7, under the state directory, never committed. */
|
|
253
262
|
const COMMIT_INDEX_FILE = 'commit-index';
|
|
263
|
+
/** commitMessageCommand's fixed time limit, then commitSubject (design 14.18, Decision 131). */
|
|
264
|
+
const MESSAGE_COMMAND_TIMEOUT_MS = 5000;
|
|
265
|
+
/** The most of a message command's stdout enmetu reads; a message is far shorter. */
|
|
266
|
+
const MESSAGE_COMMAND_OUTPUT_CAP_BYTES = 65536;
|
|
267
|
+
/**
|
|
268
|
+
* A host writer's time limit: the command's own limit, which the writer may
|
|
269
|
+
* wait for through runCommand, plus room for its own git reads (design 14.19,
|
|
270
|
+
* Decision 135).
|
|
271
|
+
*/
|
|
272
|
+
const HOST_MESSAGE_TIMEOUT_MS = 10000;
|
|
254
273
|
/**
|
|
255
274
|
* The commit step bound into a closure (design 12.7): a second facade whose
|
|
256
275
|
* runner sets GIT_INDEX_FILE to the commit index, its writer behind the same
|
|
257
276
|
* halt Decorator, a real-index facade for post-commit and post-index-change,
|
|
258
277
|
* both facades with the hook timeout and the daemon's hook stop signal
|
|
259
|
-
* (undefined outside the daemon), the index lock
|
|
260
|
-
* residue build
|
|
278
|
+
* (undefined outside the daemon), the index lock, the NullLogger for the
|
|
279
|
+
* residue build, the message command with its fixed time limit and the same
|
|
280
|
+
* stop signal (design 14.18), and the host's writer with its own limit and
|
|
281
|
+
* that signal when the library got one (design 14.19).
|
|
261
282
|
*/
|
|
262
|
-
function commitOf(parts, paths, config, runtime, hookStop) {
|
|
283
|
+
function commitOf(parts, paths, config, runtime, hookStop, commitMessage) {
|
|
263
284
|
const commitIndexPath = join(paths.stateDir, COMMIT_INDEX_FILE);
|
|
264
285
|
const commitFacade = new GitFacade(new GitProcessAdapter({
|
|
265
286
|
cwd: paths.root,
|
|
@@ -285,12 +306,27 @@ function commitOf(parts, paths, config, runtime, hookStop) {
|
|
|
285
306
|
},
|
|
286
307
|
realHooks,
|
|
287
308
|
indexLock: parts.indexLock,
|
|
309
|
+
messageCommand: new ShellMessageCommandAdapter({
|
|
310
|
+
cwd: paths.root,
|
|
311
|
+
baseEnv: runtime.env,
|
|
312
|
+
timeoutMs: MESSAGE_COMMAND_TIMEOUT_MS,
|
|
313
|
+
outputCapBytes: MESSAGE_COMMAND_OUTPUT_CAP_BYTES,
|
|
314
|
+
abort: hookStop,
|
|
315
|
+
}),
|
|
316
|
+
hostMessage: commitMessage === undefined
|
|
317
|
+
? null
|
|
318
|
+
: new HostMessageWriterAdapter(commitMessage, {
|
|
319
|
+
root: paths.root,
|
|
320
|
+
timeoutMs: HOST_MESSAGE_TIMEOUT_MS,
|
|
321
|
+
abort: hookStop,
|
|
322
|
+
}),
|
|
288
323
|
fs: parts.fs,
|
|
289
324
|
logger: parts.logger,
|
|
290
325
|
residueLogger: new NullLogger(),
|
|
291
326
|
clock: parts.clock,
|
|
292
327
|
settings: {
|
|
293
328
|
commitSubject: config.commitSubject,
|
|
329
|
+
commitMessageCommand: config.commitMessageCommand,
|
|
294
330
|
skipJsonCheck: config.skipJsonCheck,
|
|
295
331
|
partialStagingWaitMs: config.partialStagingWaitMs,
|
|
296
332
|
commitHooks: config.commitHooks,
|
|
@@ -303,16 +339,19 @@ function commitOf(parts, paths, config, runtime, hookStop) {
|
|
|
303
339
|
/**
|
|
304
340
|
* runCommand's deps with one process host shared by the daemon and its lock:
|
|
305
341
|
* the lock still records this process's pid and hostname, while stops come
|
|
306
|
-
* from requestStop() instead of the host program's signals.
|
|
342
|
+
* from requestStop() instead of the host program's signals. commitMessage is
|
|
343
|
+
* the host's writer, when it passed one (design 14.19).
|
|
307
344
|
*/
|
|
308
|
-
export function createEmbeddedRunDeps(runtime) {
|
|
345
|
+
export function createEmbeddedRunDeps(runtime, commitMessage) {
|
|
309
346
|
const processHost = new RequestedStopProcessHost(new NodeProcessAdapter());
|
|
347
|
+
const fs = new NodeFileSystemAdapter();
|
|
310
348
|
return {
|
|
311
349
|
deps: {
|
|
312
350
|
locator: createLocator(runtime),
|
|
313
|
-
fs
|
|
351
|
+
fs,
|
|
352
|
+
readGitConfig: gitConfigReaderFor(runtime, fs),
|
|
314
353
|
logger: new StderrLoggerAdapter(runtime.stderr, new SystemClockAdapter()),
|
|
315
|
-
createDaemon: (paths, config) => createDaemon(paths, config, runtime, processHost),
|
|
354
|
+
createDaemon: (paths, config) => createDaemon(paths, config, runtime, processHost, commitMessage),
|
|
316
355
|
},
|
|
317
356
|
requestStop: () => processHost.requestStop(),
|
|
318
357
|
};
|
package/dist/index.d.ts
CHANGED
|
@@ -10,7 +10,7 @@ export type { ExitCode } from './contracts/exitCode.js';
|
|
|
10
10
|
export { EXIT } from './contracts/exitCode.js';
|
|
11
11
|
export type { DaemonState, LastCycle, StateInstance, StateReason, StateRecord, StateTransition, } from './contracts/IStateStore.js';
|
|
12
12
|
export type { ITextSink } from './contracts/ITextSink.js';
|
|
13
|
-
export type { EnmetuInstance, EnmetuStartOptions, EnmetuStateView, StateLiveness, } from './contracts/library.js';
|
|
13
|
+
export type { CommitChange, CommitMessageWriter, EnmetuInstance, EnmetuStartOptions, EnmetuStateView, StateLiveness, } from './contracts/library.js';
|
|
14
14
|
export type { ConflictKey, CycleOutcomeKind, PauseReason, WaitReason, } from './contracts/syncTypes.js';
|
|
15
15
|
/** Starts enmetu for the repository containing options.repo, in this process. */
|
|
16
16
|
export declare function startEnmetu(options: EnmetuStartOptions): EnmetuInstance;
|
package/dist/index.js
CHANGED
|
@@ -21,6 +21,8 @@ function processRuntime() {
|
|
|
21
21
|
// records its own argv[1].
|
|
22
22
|
cliPath: fileURLToPath(new URL('./cli.js', import.meta.url)),
|
|
23
23
|
version: packageVersion(),
|
|
24
|
+
platform: process.platform,
|
|
25
|
+
engine: process.versions.bun === undefined ? 'node' : 'bun',
|
|
24
26
|
stdout: process.stdout,
|
|
25
27
|
stderr: process.stderr,
|
|
26
28
|
};
|
|
@@ -5,7 +5,8 @@ import type { Runtime } from '../contracts/runtime.js';
|
|
|
5
5
|
* without src/index.ts (which reads the process and its own module URL). startEmbedded
|
|
6
6
|
* runs runCommand with createEmbeddedRunDeps: the same locate, git version
|
|
7
7
|
* gate, config and daemon `enmetu run` uses, but with RequestedStopProcessHost,
|
|
8
|
-
* so stop() takes the place of the first SIGTERM
|
|
8
|
+
* so stop() takes the place of the first SIGTERM, and with the host's
|
|
9
|
+
* commitMessage writer when it passed one (design 14.19). options.repo is resolved
|
|
9
10
|
* against runtime.cwd. It returns at once; done resolves when the run ends and
|
|
10
11
|
* never rejects: an error runCommand throws is written to the log sink in the
|
|
11
12
|
* form `enmetu: <message>` and mapped by exitCodeFor, like the CLI's main, so a
|
|
@@ -9,7 +9,8 @@ import { readStateController } from './readStateController.js';
|
|
|
9
9
|
* without src/index.ts (which reads the process and its own module URL). startEmbedded
|
|
10
10
|
* runs runCommand with createEmbeddedRunDeps: the same locate, git version
|
|
11
11
|
* gate, config and daemon `enmetu run` uses, but with RequestedStopProcessHost,
|
|
12
|
-
* so stop() takes the place of the first SIGTERM
|
|
12
|
+
* so stop() takes the place of the first SIGTERM, and with the host's
|
|
13
|
+
* commitMessage writer when it passed one (design 14.19). options.repo is resolved
|
|
13
14
|
* against runtime.cwd. It returns at once; done resolves when the run ends and
|
|
14
15
|
* never rejects: an error runCommand throws is written to the log sink in the
|
|
15
16
|
* form `enmetu: <message>` and mapped by exitCodeFor, like the CLI's main, so a
|
|
@@ -17,7 +18,7 @@ import { readStateController } from './readStateController.js';
|
|
|
17
18
|
*/
|
|
18
19
|
export function startEmbedded(options, runtime) {
|
|
19
20
|
const embedded = embeddedRuntime(options, runtime);
|
|
20
|
-
const run = createEmbeddedRunDeps(embedded);
|
|
21
|
+
const run = createEmbeddedRunDeps(embedded, options.commitMessage);
|
|
21
22
|
const done = settledRun({ startDir: resolve(runtime.cwd, options.repo) }, run.deps, embedded.stderr);
|
|
22
23
|
return {
|
|
23
24
|
done,
|
|
@@ -41,6 +42,8 @@ function embeddedRuntime(options, runtime) {
|
|
|
41
42
|
execPath: runtime.execPath,
|
|
42
43
|
cliPath: runtime.cliPath,
|
|
43
44
|
version: runtime.version,
|
|
45
|
+
platform: runtime.platform,
|
|
46
|
+
engine: runtime.engine,
|
|
44
47
|
stdout: runtime.stdout,
|
|
45
48
|
stderr: options.log ?? runtime.stderr,
|
|
46
49
|
};
|
|
@@ -27,6 +27,16 @@ export declare class PathQuarantine implements IPathQuarantine {
|
|
|
27
27
|
constructor(git: IGitReader, writer: IGitWriter, fs: IFileSystem, paths: QuarantinePaths);
|
|
28
28
|
hold(paths: readonly string[]): Promise<HoldResult>;
|
|
29
29
|
extend(paths: readonly string[]): Promise<HoldResult>;
|
|
30
|
+
/**
|
|
31
|
+
* git refused the checkout (a person's merge left a held path unmerged,
|
|
32
|
+
* design 14.20): an entry whose file still holds the bytes stored for it was
|
|
33
|
+
* never set aside, and restoring it later would write those bytes over the
|
|
34
|
+
* person's newer work, conflict markers included. Such entries leave the
|
|
35
|
+
* memento (the memento goes when none is left); an entry whose file changed
|
|
36
|
+
* stays, so no write goes unrecorded. Entries before `from` (an earlier
|
|
37
|
+
* hold that extend added to) stay as they are.
|
|
38
|
+
*/
|
|
39
|
+
private forgetUnheld;
|
|
30
40
|
/**
|
|
31
41
|
* git's checkout writes the index's version over a file whatever the file
|
|
32
42
|
* holds, so a write that landed after storeEntries read it would be gone
|