@fluado/enmetu 0.2.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +60 -8
- package/dist/adapters/git/gitFacade.d.ts +8 -0
- package/dist/adapters/git/gitFacade.js +34 -1
- package/dist/adapters/git/gitProcessAdapter.js +1 -48
- package/dist/adapters/host/hostMessageWriterAdapter.d.ts +23 -0
- package/dist/adapters/host/hostMessageWriterAdapter.js +76 -0
- package/dist/adapters/process/processGroup.d.ts +18 -0
- package/dist/adapters/process/processGroup.js +52 -0
- package/dist/adapters/process/shellMessageCommandAdapter.d.ts +24 -0
- package/dist/adapters/process/shellMessageCommandAdapter.js +102 -0
- package/dist/adapters/watch/watcherFor.d.ts +22 -0
- package/dist/adapters/watch/watcherFor.js +29 -0
- package/dist/cli/commands/hookCommand.d.ts +2 -1
- package/dist/cli/commands/hookCommand.js +14 -8
- package/dist/cli/commands/runCommand.d.ts +10 -3
- package/dist/cli/commands/runCommand.js +45 -9
- package/dist/cli.js +2 -0
- package/dist/commit/commitLocalChangesController.d.ts +37 -6
- package/dist/commit/commitLocalChangesController.js +134 -18
- package/dist/commit/stageGuardedChanges.d.ts +3 -1
- package/dist/commit/stageGuardedChanges.js +5 -4
- package/dist/contracts/ICommitMessageCommand.d.ts +32 -0
- package/dist/contracts/ICommitMessageCommand.js +1 -0
- package/dist/contracts/IGitConfigReader.d.ts +4 -1
- package/dist/contracts/IGitReader.d.ts +5 -0
- package/dist/contracts/IHostMessageWriter.d.ts +28 -0
- package/dist/contracts/IHostMessageWriter.js +1 -0
- package/dist/contracts/config.d.ts +34 -1
- package/dist/contracts/errors.d.ts +5 -1
- package/dist/contracts/errors.js +5 -1
- package/dist/contracts/library.d.ts +30 -0
- package/dist/contracts/logCodes.d.ts +1 -1
- package/dist/contracts/runtime.d.ts +4 -0
- package/dist/contracts/syncTypes.d.ts +23 -4
- package/dist/contracts/syncTypes.js +2 -0
- package/dist/core/commit/commitMessage.d.ts +27 -0
- package/dist/core/commit/commitMessage.js +61 -0
- package/dist/core/config/parseConfig.d.ts +7 -4
- package/dist/core/config/parseConfig.js +172 -26
- package/dist/core/git/porcelain.d.ts +7 -0
- package/dist/core/git/porcelain.js +24 -0
- package/dist/core/quarantine/restoreWarnings.d.ts +6 -3
- package/dist/core/quarantine/restoreWarnings.js +12 -3
- package/dist/core/state/stateGuards.js +1 -0
- package/dist/core/status/statusHeadline.js +3 -0
- package/dist/enmetuInstances.d.ts +7 -4
- package/dist/enmetuInstances.js +52 -13
- package/dist/index.d.ts +1 -1
- package/dist/index.js +2 -0
- package/dist/library/enmetuLibrary.d.ts +2 -1
- package/dist/library/enmetuLibrary.js +5 -2
- package/dist/quarantine/pathQuarantine.d.ts +10 -0
- package/dist/quarantine/pathQuarantine.js +44 -2
- package/dist/sync/integrateUpstreamController.d.ts +16 -2
- package/dist/sync/integrateUpstreamController.js +21 -1
- package/dist/sync/syncCycleController.d.ts +6 -1
- package/dist/sync/syncCycleController.js +24 -2
- package/dist/sync/syncCycleRunner.js +1 -0
- package/package.json +1 -1
|
@@ -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
|
-
/**
|
|
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
|
-
|
|
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);
|
package/dist/contracts/errors.js
CHANGED
|
@@ -29,9 +29,13 @@ export class GitOutputError extends EnmetuError {
|
|
|
29
29
|
}
|
|
30
30
|
export class ConfigError extends EnmetuError {
|
|
31
31
|
key;
|
|
32
|
-
|
|
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' | 'HELD' | 'RESTORED';
|
|
@@ -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
|
}
|
|
@@ -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
|
|
15
|
-
* a person has a selection staged and partialStagingWaitMs has not
|
|
16
|
-
* (design 14.17),
|
|
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
|
} | {
|
|
@@ -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;
|
|
@@ -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('!'));
|
|
@@ -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
|
|
13
|
-
*
|
|
14
|
-
*
|
|
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
|
|
8
|
-
*
|
|
9
|
-
*
|
|
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',
|