@fluado/enmetu 0.1.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +99 -10
- package/dist/adapters/git/gitFacade.d.ts +8 -0
- package/dist/adapters/git/gitFacade.js +34 -1
- package/dist/adapters/git/gitLocatorFacade.js +6 -3
- 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 +3 -1
- package/dist/cli/commands/hookCommand.js +15 -7
- package/dist/cli/commands/runCommand.d.ts +10 -2
- package/dist/cli/commands/runCommand.js +51 -5
- package/dist/cli/exitCodes.js +0 -1
- package/dist/cli.js +2 -0
- package/dist/commit/commitLocalChangesController.d.ts +28 -7
- package/dist/commit/commitLocalChangesController.js +135 -27
- 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/IRepositoryLocator.d.ts +5 -1
- package/dist/contracts/config.d.ts +44 -5
- package/dist/contracts/errors.d.ts +7 -3
- 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 +3 -3
- package/dist/contracts/syncTypes.js +1 -1
- package/dist/core/commit/commitMemory.js +1 -1
- package/dist/core/commit/commitMessage.d.ts +27 -0
- package/dist/core/commit/commitMessage.js +61 -0
- package/dist/core/config/parseConfig.d.ts +9 -3
- package/dist/core/config/parseConfig.js +186 -60
- package/dist/core/git/porcelain.d.ts +9 -1
- package/dist/core/git/porcelain.js +27 -3
- package/dist/core/status/statusHeadline.js +1 -1
- package/dist/enmetuInstances.d.ts +7 -4
- package/dist/enmetuInstances.js +53 -15
- package/dist/hooks/preCommitHookInstaller.js +10 -0
- 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/syncCycleController.d.ts +1 -1
- package/dist/sync/syncCycleController.js +2 -2
- package/dist/sync/syncCycleRunner.js +1 -1
- package/package.json +1 -1
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
/** What enmetu does while a person has a selection staged (design 12.7). */
|
|
2
|
-
export type StagedSelectionMode = 'wait' | 'commit';
|
|
3
1
|
export interface EnmetuConfig {
|
|
4
2
|
readonly quietMs: number;
|
|
5
3
|
readonly maxDelayMs: number;
|
|
@@ -10,11 +8,52 @@ export interface EnmetuConfig {
|
|
|
10
8
|
readonly gitTimeoutMs: number;
|
|
11
9
|
/** Transitions kept in state.json; 0 keeps none (design 12.1). */
|
|
12
10
|
readonly stateHistory: number;
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
11
|
+
/**
|
|
12
|
+
* While a person has a partial staging, how long enmetu waits before it
|
|
13
|
+
* commits everything, the staging included; 0 commits at once (design 14.17).
|
|
14
|
+
*/
|
|
15
|
+
readonly partialStagingWaitMs: number;
|
|
16
16
|
/** Run the repository's commit hooks for enmetu's commits (design 12.7). */
|
|
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;
|
|
35
|
+
}
|
|
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). */
|
|
45
|
+
export interface ParsedConfig {
|
|
46
|
+
readonly config: EnmetuConfig;
|
|
47
|
+
/** Ignored; the run command names each once at start. */
|
|
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;
|
|
20
59
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { HaltCode } from './IHaltStore.js';
|
|
2
|
-
export type EnmetuErrorCode = 'GIT_NOT_FOUND' | 'GIT_TIMEOUT' | 'GIT_ABORTED' | 'GIT_FAILED' | 'GIT_INDEX_LOCKED' | 'GIT_OUTPUT_UNPARSEABLE' | 'CONFIG_INVALID_JSON' | '
|
|
2
|
+
export type EnmetuErrorCode = 'GIT_NOT_FOUND' | 'GIT_TIMEOUT' | 'GIT_ABORTED' | 'GIT_FAILED' | 'GIT_INDEX_LOCKED' | 'GIT_OUTPUT_UNPARSEABLE' | 'CONFIG_INVALID_JSON' | 'CONFIG_INVALID_VALUE' | 'NOT_A_REPOSITORY' | 'BARE_REPOSITORY' | 'GIT_TOO_OLD' | 'PATH_UNSUPPORTED' | 'OPERATION_IN_PROGRESS' | 'DETACHED_HEAD' | 'HEAD_UNBORN' | 'NO_UPSTREAM' | 'UPSTREAM_NOT_REMOTE' | 'IDENTITY_MISSING' | 'LOCK_HELD' | 'LOCK_UNREADABLE' | 'HALTED' | 'USAGE';
|
|
3
3
|
export type GitCommandErrorCode = 'GIT_NOT_FOUND' | 'GIT_TIMEOUT' | 'GIT_ABORTED' | 'GIT_FAILED' | 'GIT_INDEX_LOCKED';
|
|
4
|
-
export type ConfigErrorCode = 'CONFIG_INVALID_JSON' | '
|
|
4
|
+
export type ConfigErrorCode = 'CONFIG_INVALID_JSON' | 'CONFIG_INVALID_VALUE';
|
|
5
5
|
export type PreflightErrorCode = 'NOT_A_REPOSITORY' | 'BARE_REPOSITORY' | 'GIT_TOO_OLD' | 'PATH_UNSUPPORTED' | 'OPERATION_IN_PROGRESS' | 'DETACHED_HEAD' | 'HEAD_UNBORN' | 'NO_UPSTREAM' | 'UPSTREAM_NOT_REMOTE' | 'IDENTITY_MISSING';
|
|
6
6
|
export type LockErrorCode = 'LOCK_HELD' | 'LOCK_UNREADABLE';
|
|
7
7
|
export declare class EnmetuError extends Error {
|
|
@@ -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' | '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
|
}
|
|
@@ -12,8 +12,8 @@ export type PauseReason = 'FOREIGN_OPERATION' | 'DETACHED_HEAD' | 'NO_UPSTREAM'
|
|
|
12
12
|
| 'REBASE_REFUSED';
|
|
13
13
|
/**
|
|
14
14
|
* Why a cycle committed nothing although the tree has changes (design 12.7):
|
|
15
|
-
* a person has a selection staged
|
|
16
|
-
* commit hook rejected enmetu's commit.
|
|
15
|
+
* a person has a selection staged and partialStagingWaitMs has not passed
|
|
16
|
+
* (design 14.17), or a repository commit hook rejected enmetu's commit.
|
|
17
17
|
*/
|
|
18
18
|
export type WaitReason = 'PARTIAL_SELECTION_STAGED' | 'COMMIT_HOOK_REJECTED';
|
|
19
19
|
export type CycleOutcome = {
|
|
@@ -61,7 +61,7 @@ export interface HeadMove {
|
|
|
61
61
|
*/
|
|
62
62
|
export interface CommitMemory {
|
|
63
63
|
/** Epoch ms of the first cycle that found the real index differing from HEAD; null when it does not. */
|
|
64
|
-
readonly
|
|
64
|
+
readonly partialStagingSince: number | null;
|
|
65
65
|
readonly rejection: HookRejection | null;
|
|
66
66
|
/** The tree a fresh build gave right after enmetu's last commit with hooks; null when hooks left nothing. */
|
|
67
67
|
readonly residueTree: string | null;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/** The memory with lastMove replaced, every other field kept (Decision 98); the commit step and the cycle both set it. */
|
|
2
2
|
export function withLastMove(memory, lastMove) {
|
|
3
3
|
return {
|
|
4
|
-
|
|
4
|
+
partialStagingSince: memory.partialStagingSince,
|
|
5
5
|
rejection: memory.rejection,
|
|
6
6
|
residueTree: memory.residueTree,
|
|
7
7
|
lastMove,
|
|
@@ -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,5 +1,11 @@
|
|
|
1
|
-
import type { EnmetuConfig } 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
|
-
/**
|
|
5
|
-
|
|
4
|
+
/**
|
|
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.
|
|
10
|
+
*/
|
|
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';
|
|
@@ -11,10 +12,10 @@ export const DEFAULT_CONFIG = {
|
|
|
11
12
|
skipJsonCheck: [],
|
|
12
13
|
gitTimeoutMs: 120000,
|
|
13
14
|
stateHistory: 20,
|
|
14
|
-
|
|
15
|
-
stagedSelectionMaxWaitMs: null,
|
|
15
|
+
partialStagingWaitMs: 300000,
|
|
16
16
|
commitHooks: true,
|
|
17
17
|
hookTimeoutMs: 600000,
|
|
18
|
+
commitMessageCommand: null,
|
|
18
19
|
};
|
|
19
20
|
const KNOWN_KEYS = [
|
|
20
21
|
'quietMs',
|
|
@@ -25,32 +26,95 @@ const KNOWN_KEYS = [
|
|
|
25
26
|
'skipJsonCheck',
|
|
26
27
|
'gitTimeoutMs',
|
|
27
28
|
'stateHistory',
|
|
28
|
-
'
|
|
29
|
-
'stagedSelectionMaxWaitMs',
|
|
29
|
+
'partialStagingWaitMs',
|
|
30
30
|
'commitHooks',
|
|
31
31
|
'hookTimeoutMs',
|
|
32
|
+
'commitMessageCommand',
|
|
32
33
|
];
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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.';
|
|
50
|
+
/** The range of hookTimeoutMs: one second to one day (design 12.7). */
|
|
51
|
+
const MIN_HOOK_TIMEOUT_MS = 1000;
|
|
52
|
+
const MAX_HOOK_TIMEOUT_MS = 86400000;
|
|
53
|
+
/**
|
|
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.
|
|
59
|
+
*/
|
|
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
|
+
}
|
|
41
74
|
}
|
|
75
|
+
for (const setting of git.settings) {
|
|
76
|
+
settings[setting.key] = setting.value;
|
|
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) {
|
|
42
92
|
const parsed = parseJson(text);
|
|
43
93
|
if (!isRecord(parsed)) {
|
|
44
94
|
throw new ConfigError('the top level must be a JSON object', 'CONFIG_INVALID_VALUE', null);
|
|
45
95
|
}
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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;
|
|
50
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);
|
|
51
114
|
}
|
|
115
|
+
}
|
|
116
|
+
function configOf(parsed) {
|
|
52
117
|
const quietMs = readInteger(parsed, 'quietMs', 100, 600000);
|
|
53
|
-
const stagedSelection = readStagedSelection(parsed);
|
|
54
118
|
return {
|
|
55
119
|
quietMs,
|
|
56
120
|
maxDelayMs: readInteger(parsed, 'maxDelayMs', quietMs, 3600000),
|
|
@@ -60,12 +124,93 @@ export function parseConfig(text) {
|
|
|
60
124
|
skipJsonCheck: readSkipJsonCheck(parsed),
|
|
61
125
|
gitTimeoutMs: readInteger(parsed, 'gitTimeoutMs', 1000, 3600000),
|
|
62
126
|
stateHistory: readStateHistory(parsed),
|
|
63
|
-
|
|
64
|
-
stagedSelectionMaxWaitMs: readStagedSelectionMaxWaitMs(parsed, stagedSelection),
|
|
127
|
+
partialStagingWaitMs: readPartialStagingWaitMs(parsed),
|
|
65
128
|
commitHooks: readBoolean(parsed, 'commitHooks'),
|
|
66
129
|
hookTimeoutMs: readHookTimeoutMs(parsed),
|
|
130
|
+
commitMessageCommand: readCommitMessageCommand(parsed),
|
|
67
131
|
};
|
|
68
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);
|
|
213
|
+
}
|
|
69
214
|
function parseJson(text) {
|
|
70
215
|
try {
|
|
71
216
|
const parsed = JSON.parse(text);
|
|
@@ -105,20 +250,8 @@ function readCommitSubject(parsed) {
|
|
|
105
250
|
if (value === undefined) {
|
|
106
251
|
return DEFAULT_CONFIG.commitSubject;
|
|
107
252
|
}
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
throw invalid('commitSubject', rule);
|
|
111
|
-
}
|
|
112
|
-
const characters = Array.from(value).length;
|
|
113
|
-
// git aborts a commit whose message is empty after cleanup, and a person's
|
|
114
|
-
// commit.cleanup=strip removes a line starting with #; either would make
|
|
115
|
-
// every daemon commit fail.
|
|
116
|
-
if (characters < 1 ||
|
|
117
|
-
characters > MAX_SUBJECT_CHARACTERS ||
|
|
118
|
-
/[\r\n]/.test(value) ||
|
|
119
|
-
value.startsWith('#') ||
|
|
120
|
-
value.trim() === '') {
|
|
121
|
-
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 #`);
|
|
122
255
|
}
|
|
123
256
|
return value;
|
|
124
257
|
}
|
|
@@ -158,39 +291,32 @@ function readSkipJsonCheck(parsed) {
|
|
|
158
291
|
function readStateHistory(parsed) {
|
|
159
292
|
return readInteger(parsed, 'stateHistory', 0, MAX_STATE_HISTORY);
|
|
160
293
|
}
|
|
161
|
-
/** stagedSelection: "wait" or "commit" (design 12.7). */
|
|
162
|
-
function readStagedSelection(parsed) {
|
|
163
|
-
const value = parsed.stagedSelection;
|
|
164
|
-
if (value === undefined) {
|
|
165
|
-
return DEFAULT_CONFIG.stagedSelection;
|
|
166
|
-
}
|
|
167
|
-
if (value === 'wait' || value === 'commit') {
|
|
168
|
-
return value;
|
|
169
|
-
}
|
|
170
|
-
throw invalid('stagedSelection', 'must be "wait" or "commit"');
|
|
171
|
-
}
|
|
172
294
|
/**
|
|
173
|
-
*
|
|
174
|
-
*
|
|
295
|
+
* partialStagingWaitMs: an integer of 0 or more (design 14.17). There is no
|
|
296
|
+
* wait without an end, so null is refused like any other non-integer.
|
|
175
297
|
*/
|
|
176
|
-
function
|
|
177
|
-
const value = parsed.
|
|
178
|
-
if (value === undefined
|
|
179
|
-
return DEFAULT_CONFIG.
|
|
180
|
-
}
|
|
181
|
-
const rule = `must be null or an integer from ${MIN_WAIT_MS} to ${MAX_WAIT_MS}`;
|
|
182
|
-
if (typeof value !== 'number' ||
|
|
183
|
-
!Number.isInteger(value) ||
|
|
184
|
-
value < MIN_WAIT_MS ||
|
|
185
|
-
value > MAX_WAIT_MS) {
|
|
186
|
-
throw invalid('stagedSelectionMaxWaitMs', rule);
|
|
298
|
+
function readPartialStagingWaitMs(parsed) {
|
|
299
|
+
const value = parsed.partialStagingWaitMs;
|
|
300
|
+
if (value === undefined) {
|
|
301
|
+
return DEFAULT_CONFIG.partialStagingWaitMs;
|
|
187
302
|
}
|
|
188
|
-
if (
|
|
189
|
-
throw invalid('
|
|
303
|
+
if (typeof value !== 'number' || !Number.isInteger(value) || value < 0) {
|
|
304
|
+
throw invalid('partialStagingWaitMs', 'must be an integer of 0 or more');
|
|
190
305
|
}
|
|
191
306
|
return value;
|
|
192
307
|
}
|
|
193
308
|
/** hookTimeoutMs: integer 1000..86400000 (design 12.7, Decision 83). */
|
|
194
309
|
function readHookTimeoutMs(parsed) {
|
|
195
|
-
return readInteger(parsed, 'hookTimeoutMs',
|
|
310
|
+
return readInteger(parsed, 'hookTimeoutMs', MIN_HOOK_TIMEOUT_MS, MAX_HOOK_TIMEOUT_MS);
|
|
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;
|
|
196
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
|
/**
|
|
@@ -27,7 +28,8 @@ export interface BatchBlob {
|
|
|
27
28
|
export interface RevParsePaths {
|
|
28
29
|
readonly root: string;
|
|
29
30
|
readonly gitDir: string;
|
|
30
|
-
|
|
31
|
+
/** --git-common-dir: the main repository's git dir, also from a linked worktree. */
|
|
32
|
+
readonly commonDir: string;
|
|
31
33
|
}
|
|
32
34
|
/** `git status --porcelain=v1 -z` records; a rename or copy record consumes its source path field. */
|
|
33
35
|
export declare function parseStatus(stdout: Buffer): readonly StatusEntry[];
|
|
@@ -47,5 +49,11 @@ export declare function parseBatch(stdout: Buffer): readonly BatchBlob[];
|
|
|
47
49
|
export declare function parseUpstream(stdout: string, branchRef: string): Upstream | null;
|
|
48
50
|
/** The three absolute paths of rev-parse; null unless exactly three absolute lines (a path with a newline). */
|
|
49
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[];
|
|
50
58
|
/** true when `git push --porcelain` output carries a ref line starting with `!`. */
|
|
51
59
|
export declare function hasRejectedRef(stdout: string): boolean;
|