@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
|
@@ -7,7 +7,8 @@ const HOOK_CYCLE_ID = 'hook';
|
|
|
7
7
|
/**
|
|
8
8
|
* Controller:
|
|
9
9
|
* 1. locator.locate(startDir); anything but found: notice, exit 0 (the hook fails open).
|
|
10
|
-
* 2. parseConfig of <root>/enmetu.config.json
|
|
10
|
+
* 2. parseConfig of <root>/enmetu.config.json with git config over it, as the
|
|
11
|
+
* daemon reads it (design 14.18); a ConfigError prints a notice and
|
|
11
12
|
* applies the defaults, so a broken config never blocks the commit that fixes it.
|
|
12
13
|
* Unknown keys are ignored without a word: the daemon names them at start (design 14.17).
|
|
13
14
|
* 3. preCommitCheckController with the config's skipJsonCheck.
|
|
@@ -21,10 +22,11 @@ export async function hookCommand(input, deps) {
|
|
|
21
22
|
return { exitCode: EXIT.OK };
|
|
22
23
|
}
|
|
23
24
|
const { paths } = location;
|
|
24
|
-
|
|
25
|
-
|
|
25
|
+
const git = deps.openGit(paths);
|
|
26
|
+
// --- Step 2: the config at the root and git config; defaults when it is broken ---
|
|
27
|
+
const config = await configForCheck(paths.root, git, deps);
|
|
26
28
|
// --- Step 3: the shared content rules over the staged blobs (D11) ---
|
|
27
|
-
const { violations } = await preCommitCheckController({ cycleId: HOOK_CYCLE_ID }, { git
|
|
29
|
+
const { violations } = await preCommitCheckController({ cycleId: HOOK_CYCLE_ID }, { git, skipJsonCheck: config.skipJsonCheck });
|
|
28
30
|
// --- Step 4: the refusal, or silence ---
|
|
29
31
|
if (violations.length === 0) {
|
|
30
32
|
return { exitCode: EXIT.OK };
|
|
@@ -56,16 +58,20 @@ async function locateOrReason(startDir, deps) {
|
|
|
56
58
|
};
|
|
57
59
|
}
|
|
58
60
|
}
|
|
59
|
-
async function configForCheck(root, deps) {
|
|
61
|
+
async function configForCheck(root, git, deps) {
|
|
62
|
+
const text = await deps.fs.readText(join(root, CONFIG_FILE_NAME));
|
|
63
|
+
const gitEntries = await git.enmetuEntries();
|
|
60
64
|
try {
|
|
61
|
-
return parseConfig(
|
|
62
|
-
.config;
|
|
65
|
+
return parseConfig(text, gitEntries).config;
|
|
63
66
|
}
|
|
64
67
|
catch (error) {
|
|
65
68
|
if (!(error instanceof ConfigError)) {
|
|
66
69
|
throw error;
|
|
67
70
|
}
|
|
68
|
-
|
|
71
|
+
const source = error.gitScope === null
|
|
72
|
+
? CONFIG_FILE_NAME
|
|
73
|
+
: `git config enmetu.${error.key ?? ''} (${error.gitScope})`;
|
|
74
|
+
deps.stderr.write(`enmetu: ignoring ${source} (${error.code}: ${error.message}); using the defaults for this check\n`);
|
|
69
75
|
return DEFAULT_CONFIG;
|
|
70
76
|
}
|
|
71
77
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { EnmetuConfig } from '../../contracts/config.js';
|
|
1
|
+
import type { EnmetuConfig, GitConfigEntry } from '../../contracts/config.js';
|
|
2
2
|
import { type ExitCode } from '../../contracts/exitCode.js';
|
|
3
3
|
import type { IDaemon } from '../../contracts/IDaemon.js';
|
|
4
4
|
import type { IFileSystem } from '../../contracts/IFileSystem.js';
|
|
@@ -11,6 +11,8 @@ export interface RunCommandInput {
|
|
|
11
11
|
export interface RunCommandDeps {
|
|
12
12
|
readonly locator: IRepositoryLocator;
|
|
13
13
|
readonly fs: IFileSystem;
|
|
14
|
+
/** Every enmetu.* entry of git config for the resolved repository (design 14.18). */
|
|
15
|
+
readonly readGitConfig: (paths: RepositoryPaths) => Promise<readonly GitConfigEntry[]>;
|
|
14
16
|
readonly logger: ILogger;
|
|
15
17
|
/** Composition-root closure; builds the daemon for the resolved repository. */
|
|
16
18
|
readonly createDaemon: (paths: RepositoryPaths, config: EnmetuConfig) => IDaemon;
|
|
@@ -27,8 +29,13 @@ export declare const MIN_GIT_VERSION: {
|
|
|
27
29
|
* Controller:
|
|
28
30
|
* 1. locator.locate(startDir): not a repository, bare, or PATH_UNSUPPORTED log NOT_ELIGIBLE, exit 5.
|
|
29
31
|
* 2. Git older than 2.38: NOT_ELIGIBLE (GIT_TOO_OLD), exit 5.
|
|
30
|
-
* 3. parseConfig of <root>/enmetu.config.json
|
|
31
|
-
*
|
|
32
|
+
* 3. parseConfig of <root>/enmetu.config.json and readGitConfig(paths), git
|
|
33
|
+
* config overriding the file (design 14.18): ConfigError logs
|
|
34
|
+
* CONFIG_INVALID naming the file or the git config key and scope, exit 2;
|
|
35
|
+
* each unknown key of the file, each key of the file only git config may
|
|
36
|
+
* set and each unknown enmetu.* key of git config logs CONFIG_KEY_IGNORED
|
|
37
|
+
* once and is ignored (design 14.17); each setting git config set logs
|
|
38
|
+
* CONFIG_FROM_GIT with its scope.
|
|
32
39
|
* 4. createDaemon(paths, config).run(), its exit code returned.
|
|
33
40
|
*/
|
|
34
41
|
export declare function runCommand(input: RunCommandInput, deps: RunCommandDeps): Promise<RunCommandResult>;
|
|
@@ -8,8 +8,13 @@ export const MIN_GIT_VERSION = { major: 2, minor: 38 };
|
|
|
8
8
|
* Controller:
|
|
9
9
|
* 1. locator.locate(startDir): not a repository, bare, or PATH_UNSUPPORTED log NOT_ELIGIBLE, exit 5.
|
|
10
10
|
* 2. Git older than 2.38: NOT_ELIGIBLE (GIT_TOO_OLD), exit 5.
|
|
11
|
-
* 3. parseConfig of <root>/enmetu.config.json
|
|
12
|
-
*
|
|
11
|
+
* 3. parseConfig of <root>/enmetu.config.json and readGitConfig(paths), git
|
|
12
|
+
* config overriding the file (design 14.18): ConfigError logs
|
|
13
|
+
* CONFIG_INVALID naming the file or the git config key and scope, exit 2;
|
|
14
|
+
* each unknown key of the file, each key of the file only git config may
|
|
15
|
+
* set and each unknown enmetu.* key of git config logs CONFIG_KEY_IGNORED
|
|
16
|
+
* once and is ignored (design 14.17); each setting git config set logs
|
|
17
|
+
* CONFIG_FROM_GIT with its scope.
|
|
13
18
|
* 4. createDaemon(paths, config).run(), its exit code returned.
|
|
14
19
|
*/
|
|
15
20
|
export async function runCommand(input, deps) {
|
|
@@ -35,34 +40,65 @@ export async function runCommand(input, deps) {
|
|
|
35
40
|
if (olderThanMinimum(version)) {
|
|
36
41
|
return notEligible(deps, 'GIT_TOO_OLD', `git ${version.major}.${version.minor}.${version.patch} is older than ${MIN_GIT_VERSION.major}.${MIN_GIT_VERSION.minor}`);
|
|
37
42
|
}
|
|
38
|
-
// --- Step 3: the config at the resolved root,
|
|
43
|
+
// --- Step 3: the config at the resolved root, then git config over it ---
|
|
39
44
|
const configPath = join(paths.root, CONFIG_FILE_NAME);
|
|
40
45
|
let parsed;
|
|
41
46
|
try {
|
|
42
|
-
parsed = parseConfig(await deps.fs.readText(configPath));
|
|
47
|
+
parsed = parseConfig(await deps.fs.readText(configPath), await deps.readGitConfig(paths));
|
|
43
48
|
}
|
|
44
49
|
catch (error) {
|
|
45
50
|
if (!(error instanceof ConfigError)) {
|
|
46
51
|
throw error;
|
|
47
52
|
}
|
|
53
|
+
const source = error.gitScope === null
|
|
54
|
+
? configPath
|
|
55
|
+
: `git config enmetu.${error.key ?? ''} (${error.gitScope})`;
|
|
48
56
|
deps.logger.log({
|
|
49
57
|
level: 'ERROR',
|
|
50
58
|
code: 'CONFIG_INVALID',
|
|
51
|
-
message: `${
|
|
59
|
+
message: `${source}: ${error.code}: ${error.message}`,
|
|
52
60
|
});
|
|
53
61
|
return { exitCode: EXIT.USAGE };
|
|
54
62
|
}
|
|
63
|
+
logConfigSources(parsed, deps.logger);
|
|
64
|
+
const { config } = parsed;
|
|
65
|
+
// --- Step 4: the daemon for this repository ---
|
|
66
|
+
return { exitCode: await deps.createDaemon(paths, config).run() };
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Step 3's lines, before the daemon starts: what was ignored, then every
|
|
70
|
+
* setting git config set, so nobody wonders why one clone behaves differently.
|
|
71
|
+
*/
|
|
72
|
+
function logConfigSources(parsed, logger) {
|
|
55
73
|
// A key only a newer enmetu knows must not stop this one (design 14.17).
|
|
56
74
|
for (const key of parsed.unknownKeys) {
|
|
57
|
-
|
|
75
|
+
logger.log({
|
|
58
76
|
level: 'WARN',
|
|
59
77
|
code: 'CONFIG_KEY_IGNORED',
|
|
60
78
|
message: `${CONFIG_FILE_NAME}: unknown setting "${key}", ignored`,
|
|
61
79
|
});
|
|
62
80
|
}
|
|
63
|
-
const
|
|
64
|
-
|
|
65
|
-
|
|
81
|
+
for (const key of parsed.gitOnlyKeysInFile) {
|
|
82
|
+
logger.log({
|
|
83
|
+
level: 'WARN',
|
|
84
|
+
code: 'CONFIG_KEY_IGNORED',
|
|
85
|
+
message: `${CONFIG_FILE_NAME}: ${key} is read from git config only, ignored`,
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
for (const { key, scope } of parsed.unknownGitKeys) {
|
|
89
|
+
logger.log({
|
|
90
|
+
level: 'WARN',
|
|
91
|
+
code: 'CONFIG_KEY_IGNORED',
|
|
92
|
+
message: `git config (${scope}): unknown setting "${key}", ignored`,
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
for (const { key, scope, shown } of parsed.fromGit) {
|
|
96
|
+
logger.log({
|
|
97
|
+
level: 'INFO',
|
|
98
|
+
code: 'CONFIG_FROM_GIT',
|
|
99
|
+
message: `${key} ${shown} (git config, ${scope})`,
|
|
100
|
+
});
|
|
101
|
+
}
|
|
66
102
|
}
|
|
67
103
|
function olderThanMinimum(version) {
|
|
68
104
|
return (version.major < MIN_GIT_VERSION.major ||
|
package/dist/cli.js
CHANGED
|
@@ -26,6 +26,8 @@ const runtime = {
|
|
|
26
26
|
// an empty path, finds no CLI there and lets commits through (fail open).
|
|
27
27
|
cliPath: process.argv[1] ?? '',
|
|
28
28
|
version: packageVersion(),
|
|
29
|
+
platform: process.platform,
|
|
30
|
+
engine: process.versions.bun === undefined ? 'node' : 'bun',
|
|
29
31
|
stdout: process.stdout,
|
|
30
32
|
stderr: process.stderr,
|
|
31
33
|
};
|
|
@@ -1,13 +1,17 @@
|
|
|
1
1
|
import type { IClock } from '../contracts/IClock.js';
|
|
2
|
+
import type { ICommitMessageCommand } from '../contracts/ICommitMessageCommand.js';
|
|
2
3
|
import type { IFileSystem } from '../contracts/IFileSystem.js';
|
|
3
4
|
import type { IGitCommitter, IGitHookRunner } from '../contracts/IGitCommitter.js';
|
|
4
5
|
import type { IGitIndexLock } from '../contracts/IGitIndexLock.js';
|
|
5
6
|
import type { IGitReader } from '../contracts/IGitReader.js';
|
|
6
7
|
import type { IGitWriter } from '../contracts/IGitWriter.js';
|
|
8
|
+
import type { IHostMessageWriter } from '../contracts/IHostMessageWriter.js';
|
|
7
9
|
import type { ILogger } from '../contracts/ILogger.js';
|
|
8
|
-
import type { CommitMemory, WaitReason } from '../contracts/syncTypes.js';
|
|
10
|
+
import type { CommitMemory, HoldPolicy, WaitReason } from '../contracts/syncTypes.js';
|
|
9
11
|
/** An unchanged tree a hook rejected is offered to the hooks again after this long (design 12.7). */
|
|
10
12
|
export declare const HOOK_RETRY_MS = 600000;
|
|
13
|
+
/** commitMessageCommand reads at most this much of the patch on stdin (design 14.18). */
|
|
14
|
+
export declare const MESSAGE_PATCH_CAP_BYTES = 65536;
|
|
11
15
|
export interface CommitLocalChangesInput {
|
|
12
16
|
readonly cycleId: string;
|
|
13
17
|
readonly memory: CommitMemory;
|
|
@@ -31,6 +35,8 @@ export interface CommitIndexGit {
|
|
|
31
35
|
}
|
|
32
36
|
export interface CommitSettings {
|
|
33
37
|
readonly commitSubject: string;
|
|
38
|
+
/** null: the message is commitSubject alone (design 14.18). */
|
|
39
|
+
readonly commitMessageCommand: string | null;
|
|
34
40
|
readonly skipJsonCheck: readonly string[];
|
|
35
41
|
/** How long a partial staging holds the commit back; 0 commits at once (design 14.17). */
|
|
36
42
|
readonly partialStagingWaitMs: number;
|
|
@@ -49,6 +55,10 @@ export interface CommitLocalChangesDeps {
|
|
|
49
55
|
/** Hooks on the real index: post-commit and post-index-change run after the branch moved. */
|
|
50
56
|
readonly realHooks: IGitHookRunner;
|
|
51
57
|
readonly indexLock: IGitIndexLock;
|
|
58
|
+
/** Runs commitMessageCommand; called only when it is set (design 14.18). */
|
|
59
|
+
readonly messageCommand: ICommitMessageCommand;
|
|
60
|
+
/** The host program's writer (library option commitMessage); null for the CLI (design 14.19). */
|
|
61
|
+
readonly hostMessage: IHostMessageWriter | null;
|
|
52
62
|
readonly fs: IFileSystem;
|
|
53
63
|
readonly logger: ILogger;
|
|
54
64
|
/** NullLogger from the root: the residue build must not log EXCLUDED a second time. */
|
|
@@ -56,11 +66,19 @@ export interface CommitLocalChangesDeps {
|
|
|
56
66
|
readonly clock: IClock;
|
|
57
67
|
readonly settings: CommitSettings;
|
|
58
68
|
}
|
|
69
|
+
/**
|
|
70
|
+
* hold on nothing and committed (design 14.21, Decision 147): the dirty paths
|
|
71
|
+
* a pull may set aside, which are the ones this step could not commit. `only`
|
|
72
|
+
* names the paths the content guard excluded from the build whose tree was
|
|
73
|
+
* committed or found empty; `all` says every dirty path is the hooks' residue.
|
|
74
|
+
*/
|
|
59
75
|
export type CommitLocalChangesResult = {
|
|
60
76
|
readonly kind: 'nothing';
|
|
77
|
+
readonly hold: HoldPolicy;
|
|
61
78
|
readonly memory: CommitMemory;
|
|
62
79
|
} | {
|
|
63
80
|
readonly kind: 'committed';
|
|
81
|
+
readonly hold: HoldPolicy;
|
|
64
82
|
readonly memory: CommitMemory;
|
|
65
83
|
} | {
|
|
66
84
|
readonly kind: 'waiting';
|
|
@@ -98,16 +116,29 @@ export type CommitLocalChanges = (input: CommitLocalChangesInput) => Promise<Com
|
|
|
98
116
|
* SELECTION_WAIT_ENDED and goes on; 0 goes on at once (design 14.17).
|
|
99
117
|
* 2. Build: parent = git.headCommit(); fs.copy(realIndex, commit index);
|
|
100
118
|
* committer.seedFrom(parent); stageGuardedChanges on the commit index.
|
|
101
|
-
* Nothing staged returns nothing.
|
|
102
|
-
* 3. tree = committer.writeTree(); equal to memory.residueTree returns nothing
|
|
103
|
-
*
|
|
119
|
+
* Nothing staged returns nothing, hold only the excluded paths (14.21).
|
|
120
|
+
* 3. tree = committer.writeTree(); equal to memory.residueTree returns nothing
|
|
121
|
+
* with hold all (the residue is every dirty path); equal to
|
|
122
|
+
* memory.rejection.tree within HOOK_RETRY_MS returns waiting
|
|
104
123
|
* COMMIT_HOOK_REJECTED without running a hook.
|
|
105
124
|
* 4. commitHooks: pre-commit on the commit index (rejected, timed out or
|
|
106
125
|
* killed: HOOK_REJECTED, waiting, rejection remembered); then re-stage and
|
|
107
126
|
* write the tree again, so a formatter's rewrite is in this commit. The
|
|
108
|
-
* message is commitSubject plus a newline, with or without hooks; the
|
|
109
127
|
* message hooks do not run (Decision 80). The re-stage logs through
|
|
110
|
-
* residueLogger: its EXCLUDED lines repeat the first build's
|
|
128
|
+
* residueLogger: its EXCLUDED lines repeat the first build's; its excluded
|
|
129
|
+
* paths are the ones committed's hold names (14.21).
|
|
130
|
+
* 4b. The message (design 14.18, 14.19), before index.lock, so no writer
|
|
131
|
+
* ever holds up a person's git. The command's message: when
|
|
132
|
+
* commitMessageCommand is set, messageCommand runs with
|
|
133
|
+
* git.patch(parent, tree, MESSAGE_PATCH_CAP_BYTES) on stdin and
|
|
134
|
+
* messageFromText turns its stdout into the message; a failed patch, a
|
|
135
|
+
* failed command or output messageFromText refuses log one
|
|
136
|
+
* MESSAGE_COMMAND_FAILED line and give none. Without hostMessage: the
|
|
137
|
+
* command's message, else commitSubject plus a newline. With hostMessage:
|
|
138
|
+
* its write decides, given a runCommand that yields the command's message,
|
|
139
|
+
* run at most once however often it is called; declined is commitSubject;
|
|
140
|
+
* failed, or text messageFromText refuses, logs MESSAGE_WRITER_FAILED and
|
|
141
|
+
* is commitSubject.
|
|
111
142
|
* 5. commit = committer.commitTree({ tree, parent, message, sign: gitBoolean(commit.gpgSign) }).
|
|
112
143
|
* 6. Critical section: indexLock.acquire(commit) (false: busy);
|
|
113
144
|
* 6.1 repositoryState() with an operation: release, paused; HEAD naming
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import { EnmetuError, GitCommandError } from '../contracts/errors.js';
|
|
2
2
|
import { withLastMove } from '../core/commit/commitMemory.js';
|
|
3
|
-
import { gitBoolean } from '../core/commit/commitMessage.js';
|
|
3
|
+
import { gitBoolean, messageFromText } from '../core/commit/commitMessage.js';
|
|
4
4
|
import { repairStaleIndexController } from './repairStaleIndexController.js';
|
|
5
|
-
import { stageGuardedChanges } from './stageGuardedChanges.js';
|
|
5
|
+
import { stageGuardedChanges, } from './stageGuardedChanges.js';
|
|
6
6
|
/** An unchanged tree a hook rejected is offered to the hooks again after this long (design 12.7). */
|
|
7
7
|
export const HOOK_RETRY_MS = 600000;
|
|
8
|
+
/** commitMessageCommand reads at most this much of the patch on stdin (design 14.18). */
|
|
9
|
+
export const MESSAGE_PATCH_CAP_BYTES = 65536;
|
|
8
10
|
/**
|
|
9
11
|
* Controller: enmetu's commit without touching the person's index (design
|
|
10
12
|
* 12.7, Decisions 72 to 74, 80, 81).
|
|
@@ -21,16 +23,29 @@ export const HOOK_RETRY_MS = 600000;
|
|
|
21
23
|
* SELECTION_WAIT_ENDED and goes on; 0 goes on at once (design 14.17).
|
|
22
24
|
* 2. Build: parent = git.headCommit(); fs.copy(realIndex, commit index);
|
|
23
25
|
* committer.seedFrom(parent); stageGuardedChanges on the commit index.
|
|
24
|
-
* Nothing staged returns nothing.
|
|
25
|
-
* 3. tree = committer.writeTree(); equal to memory.residueTree returns nothing
|
|
26
|
-
*
|
|
26
|
+
* Nothing staged returns nothing, hold only the excluded paths (14.21).
|
|
27
|
+
* 3. tree = committer.writeTree(); equal to memory.residueTree returns nothing
|
|
28
|
+
* with hold all (the residue is every dirty path); equal to
|
|
29
|
+
* memory.rejection.tree within HOOK_RETRY_MS returns waiting
|
|
27
30
|
* COMMIT_HOOK_REJECTED without running a hook.
|
|
28
31
|
* 4. commitHooks: pre-commit on the commit index (rejected, timed out or
|
|
29
32
|
* killed: HOOK_REJECTED, waiting, rejection remembered); then re-stage and
|
|
30
33
|
* write the tree again, so a formatter's rewrite is in this commit. The
|
|
31
|
-
* message is commitSubject plus a newline, with or without hooks; the
|
|
32
34
|
* message hooks do not run (Decision 80). The re-stage logs through
|
|
33
|
-
* residueLogger: its EXCLUDED lines repeat the first build's
|
|
35
|
+
* residueLogger: its EXCLUDED lines repeat the first build's; its excluded
|
|
36
|
+
* paths are the ones committed's hold names (14.21).
|
|
37
|
+
* 4b. The message (design 14.18, 14.19), before index.lock, so no writer
|
|
38
|
+
* ever holds up a person's git. The command's message: when
|
|
39
|
+
* commitMessageCommand is set, messageCommand runs with
|
|
40
|
+
* git.patch(parent, tree, MESSAGE_PATCH_CAP_BYTES) on stdin and
|
|
41
|
+
* messageFromText turns its stdout into the message; a failed patch, a
|
|
42
|
+
* failed command or output messageFromText refuses log one
|
|
43
|
+
* MESSAGE_COMMAND_FAILED line and give none. Without hostMessage: the
|
|
44
|
+
* command's message, else commitSubject plus a newline. With hostMessage:
|
|
45
|
+
* its write decides, given a runCommand that yields the command's message,
|
|
46
|
+
* run at most once however often it is called; declined is commitSubject;
|
|
47
|
+
* failed, or text messageFromText refuses, logs MESSAGE_WRITER_FAILED and
|
|
48
|
+
* is commitSubject.
|
|
34
49
|
* 5. commit = committer.commitTree({ tree, parent, message, sign: gitBoolean(commit.gpgSign) }).
|
|
35
50
|
* 6. Critical section: indexLock.acquire(commit) (false: busy);
|
|
36
51
|
* 6.1 repositoryState() with an operation: release, paused; HEAD naming
|
|
@@ -98,13 +113,16 @@ export async function commitLocalChangesController(input, deps) {
|
|
|
98
113
|
// The preflight refuses an unborn HEAD; this guards the type.
|
|
99
114
|
throw new EnmetuError('HEAD is unborn; there is nothing to commit onto', 'HEAD_UNBORN', cycleId);
|
|
100
115
|
}
|
|
101
|
-
|
|
102
|
-
|
|
116
|
+
const built = await build(parent, cycleId, deps.logger, deps);
|
|
117
|
+
let hold = { kind: 'only', paths: built.excluded };
|
|
118
|
+
if (!built.staged) {
|
|
119
|
+
return { kind: 'nothing', hold, memory: unchanged };
|
|
103
120
|
}
|
|
104
121
|
// --- Step 3: trees enmetu already knows ---
|
|
105
122
|
let tree = await deps.commitIndex.committer.writeTree();
|
|
106
123
|
if (tree === memory.residueTree) {
|
|
107
|
-
|
|
124
|
+
// Every dirty path is what the hooks wrote after the last commit (Decision 90).
|
|
125
|
+
return { kind: 'nothing', hold: { kind: 'all' }, memory: unchanged };
|
|
108
126
|
}
|
|
109
127
|
const rejection = memory.rejection;
|
|
110
128
|
if (rejection !== null &&
|
|
@@ -143,17 +161,21 @@ export async function commitLocalChangesController(input, deps) {
|
|
|
143
161
|
logger: deps.residueLogger,
|
|
144
162
|
skipJsonCheck: settings.skipJsonCheck,
|
|
145
163
|
});
|
|
164
|
+
hold = { kind: 'only', paths: restaged.excluded };
|
|
146
165
|
if (!restaged.staged) {
|
|
147
|
-
return { kind: 'nothing', memory: unchanged };
|
|
166
|
+
return { kind: 'nothing', hold, memory: unchanged };
|
|
148
167
|
}
|
|
149
168
|
tree = await deps.commitIndex.committer.writeTree();
|
|
150
169
|
}
|
|
170
|
+
// --- Step 4b: the message, outside the lock ---
|
|
171
|
+
const message = await commitMessage(parent, tree, cycleId, deps);
|
|
151
172
|
// --- Step 5: the commit object; HEAD is its only parent, never MERGE_HEAD ---
|
|
152
|
-
//
|
|
173
|
+
// enmetu itself puts no path into the message (D1); a person's
|
|
174
|
+
// commitMessageCommand may (design 14.18).
|
|
153
175
|
const commit = await deps.commitIndex.committer.commitTree({
|
|
154
176
|
tree,
|
|
155
177
|
parent,
|
|
156
|
-
message
|
|
178
|
+
message,
|
|
157
179
|
sign: gitBoolean(await deps.git.configValue('commit.gpgSign')),
|
|
158
180
|
});
|
|
159
181
|
// --- Step 6: critical section under git's own index.lock ---
|
|
@@ -176,6 +198,7 @@ export async function commitLocalChangesController(input, deps) {
|
|
|
176
198
|
}
|
|
177
199
|
return {
|
|
178
200
|
kind: 'committed',
|
|
201
|
+
hold,
|
|
179
202
|
memory: {
|
|
180
203
|
partialStagingSince: null,
|
|
181
204
|
rejection: null,
|
|
@@ -197,7 +220,7 @@ export async function commitLocalChangesController(input, deps) {
|
|
|
197
220
|
async function runPostCommitHooks(commit, tree, cycleId, deps) {
|
|
198
221
|
const mayWrite = (await deps.realHooks.hookExists('post-index-change')) ||
|
|
199
222
|
(await deps.realHooks.hookExists('post-commit'));
|
|
200
|
-
const pendingBefore = mayWrite && (await build(commit, cycleId, deps.residueLogger, deps));
|
|
223
|
+
const pendingBefore = mayWrite && (await build(commit, cycleId, deps.residueLogger, deps)).staged;
|
|
201
224
|
// Both run through `git hook run --ignore-missing` whatever hookExists
|
|
202
225
|
// said, so what runs is git's decision alone; git ignores both exit codes,
|
|
203
226
|
// and so does enmetu.
|
|
@@ -206,16 +229,110 @@ async function runPostCommitHooks(commit, tree, cycleId, deps) {
|
|
|
206
229
|
if (!mayWrite || pendingBefore) {
|
|
207
230
|
return null;
|
|
208
231
|
}
|
|
209
|
-
if (!(await build(commit, cycleId, deps.residueLogger, deps))) {
|
|
232
|
+
if (!(await build(commit, cycleId, deps.residueLogger, deps)).staged) {
|
|
210
233
|
return null;
|
|
211
234
|
}
|
|
212
235
|
const residue = await deps.commitIndex.committer.writeTree();
|
|
213
236
|
return residue === tree ? null : residue;
|
|
214
237
|
}
|
|
238
|
+
/**
|
|
239
|
+
* Step 4b (design 14.18, 14.19): the message. Without a host writer, the
|
|
240
|
+
* person's commitMessageCommand's, else commitSubject. With one, the
|
|
241
|
+
* writer's: it decides, and the person's command runs only when the writer
|
|
242
|
+
* asks for it, at most once. Messages are optional and cosmetic, so nothing
|
|
243
|
+
* about them ever stops a commit: every failure falls back to commitSubject
|
|
244
|
+
* with one line.
|
|
245
|
+
*/
|
|
246
|
+
async function commitMessage(parent, tree, cycleId, deps) {
|
|
247
|
+
const subjectOnly = `${deps.settings.commitSubject}\n`;
|
|
248
|
+
if (deps.hostMessage === null) {
|
|
249
|
+
return (await commandMessage(parent, tree, cycleId, deps)) ?? subjectOnly;
|
|
250
|
+
}
|
|
251
|
+
let command = null;
|
|
252
|
+
const run = await deps.hostMessage.write({
|
|
253
|
+
parent,
|
|
254
|
+
tree,
|
|
255
|
+
runCommand: () => {
|
|
256
|
+
command ??= commandMessage(parent, tree, cycleId, deps);
|
|
257
|
+
return command;
|
|
258
|
+
},
|
|
259
|
+
});
|
|
260
|
+
if (run.kind === 'declined') {
|
|
261
|
+
return subjectOnly;
|
|
262
|
+
}
|
|
263
|
+
let failure;
|
|
264
|
+
if (run.kind === 'failed') {
|
|
265
|
+
failure = run.detail;
|
|
266
|
+
}
|
|
267
|
+
else {
|
|
268
|
+
const parsed = messageFromText(run.text);
|
|
269
|
+
if (parsed.kind === 'message') {
|
|
270
|
+
return parsed.message;
|
|
271
|
+
}
|
|
272
|
+
failure = `returned ${parsed.reason}`;
|
|
273
|
+
}
|
|
274
|
+
deps.logger.log({
|
|
275
|
+
level: 'WARN',
|
|
276
|
+
code: 'MESSAGE_WRITER_FAILED',
|
|
277
|
+
message: `the host program's commitMessage ${failure}; committing with commitSubject`,
|
|
278
|
+
cycleId,
|
|
279
|
+
});
|
|
280
|
+
return subjectOnly;
|
|
281
|
+
}
|
|
282
|
+
/**
|
|
283
|
+
* commitMessageCommand on the patch from the parent to the tree (design
|
|
284
|
+
* 14.18, Decision 131): its message, or null when the setting is unset or
|
|
285
|
+
* anything failed, a failure logged once as MESSAGE_COMMAND_FAILED naming
|
|
286
|
+
* what comes instead: commitSubject, or the host writer's own message.
|
|
287
|
+
*/
|
|
288
|
+
async function commandMessage(parent, tree, cycleId, deps) {
|
|
289
|
+
const { commitMessageCommand } = deps.settings;
|
|
290
|
+
if (commitMessageCommand === null) {
|
|
291
|
+
return null;
|
|
292
|
+
}
|
|
293
|
+
let failure;
|
|
294
|
+
try {
|
|
295
|
+
const stdin = await deps.git.patch(parent, tree, MESSAGE_PATCH_CAP_BYTES);
|
|
296
|
+
const run = await deps.messageCommand.run({
|
|
297
|
+
command: commitMessageCommand,
|
|
298
|
+
stdin,
|
|
299
|
+
parent,
|
|
300
|
+
tree,
|
|
301
|
+
});
|
|
302
|
+
if (run.kind === 'failed') {
|
|
303
|
+
failure = run.detail;
|
|
304
|
+
}
|
|
305
|
+
else {
|
|
306
|
+
const parsed = messageFromText(run.stdout);
|
|
307
|
+
if (parsed.kind === 'message') {
|
|
308
|
+
return parsed.message;
|
|
309
|
+
}
|
|
310
|
+
failure = `printed ${parsed.reason}`;
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
catch (error) {
|
|
314
|
+
if (!(error instanceof GitCommandError)) {
|
|
315
|
+
throw error;
|
|
316
|
+
}
|
|
317
|
+
failure = `got no patch (${error.message})`;
|
|
318
|
+
}
|
|
319
|
+
// With a host writer the fallback is the writer's, not commitSubject.
|
|
320
|
+
const next = deps.hostMessage === null
|
|
321
|
+
? 'committing with commitSubject'
|
|
322
|
+
: "the host program's commitMessage writes the message";
|
|
323
|
+
deps.logger.log({
|
|
324
|
+
level: 'WARN',
|
|
325
|
+
code: 'MESSAGE_COMMAND_FAILED',
|
|
326
|
+
message: `commitMessageCommand ${failure}; ${next}`,
|
|
327
|
+
cycleId,
|
|
328
|
+
});
|
|
329
|
+
return null;
|
|
330
|
+
}
|
|
215
331
|
/**
|
|
216
332
|
* Step 2 of the chain: copy the real index (git's stat data and skip-worktree
|
|
217
333
|
* bits, so add -A does not re-hash every file), seed it with the commit's
|
|
218
|
-
* tree, stage the guarded working tree
|
|
334
|
+
* tree, stage the guarded working tree; staged when anything is, with the
|
|
335
|
+
* paths the content rules kept out (design 14.21).
|
|
219
336
|
*/
|
|
220
337
|
async function build(seed, cycleId, logger, deps) {
|
|
221
338
|
const { commitIndex } = deps;
|
|
@@ -225,13 +342,12 @@ async function build(seed, cycleId, logger, deps) {
|
|
|
225
342
|
await deps.fs.remove(commitIndex.path);
|
|
226
343
|
}
|
|
227
344
|
await commitIndex.committer.seedFrom(seed);
|
|
228
|
-
|
|
345
|
+
return stageGuardedChanges({ cycleId }, {
|
|
229
346
|
git: commitIndex.reader,
|
|
230
347
|
writer: commitIndex.writer,
|
|
231
348
|
logger,
|
|
232
349
|
skipJsonCheck: deps.settings.skipJsonCheck,
|
|
233
350
|
});
|
|
234
|
-
return staged;
|
|
235
351
|
}
|
|
236
352
|
/**
|
|
237
353
|
* Step 6: under index.lock, in this order (Decisions 72, 81, 121):
|
|
@@ -12,6 +12,8 @@ export interface StageGuardedDeps {
|
|
|
12
12
|
}
|
|
13
13
|
export interface StageGuardedResult {
|
|
14
14
|
readonly staged: boolean;
|
|
15
|
+
/** The paths unstaged by the content rules, in git's order: the EXCLUDED lines (design 14.21). */
|
|
16
|
+
readonly excluded: readonly string[];
|
|
15
17
|
}
|
|
16
18
|
/**
|
|
17
19
|
* Controller: stage everything, inspect the staged blobs with the shared
|
|
@@ -20,7 +22,7 @@ export interface StageGuardedResult {
|
|
|
20
22
|
* 1. writer.stageAll(); partial logs STAGE_PARTIAL.
|
|
21
23
|
* 2. For each git.stagedFiles(MAX_SCAN_BYTES): findContentViolation.
|
|
22
24
|
* 3. Violations: one writer.unstage(paths) call, EXCLUDED logged per path.
|
|
23
|
-
* 4. staged = git.hasStagedChanges().
|
|
25
|
+
* 4. staged = git.hasStagedChanges(); excluded = the violations' paths.
|
|
24
26
|
*
|
|
25
27
|
* Inspecting the staged blobs rather than the working tree means what is
|
|
26
28
|
* inspected is exactly what the next commit records (Decision 5). The
|
|
@@ -6,7 +6,7 @@ import { findContentViolation, MAX_SCAN_BYTES, } from '../core/content/contentRu
|
|
|
6
6
|
* 1. writer.stageAll(); partial logs STAGE_PARTIAL.
|
|
7
7
|
* 2. For each git.stagedFiles(MAX_SCAN_BYTES): findContentViolation.
|
|
8
8
|
* 3. Violations: one writer.unstage(paths) call, EXCLUDED logged per path.
|
|
9
|
-
* 4. staged = git.hasStagedChanges().
|
|
9
|
+
* 4. staged = git.hasStagedChanges(); excluded = the violations' paths.
|
|
10
10
|
*
|
|
11
11
|
* Inspecting the staged blobs rather than the working tree means what is
|
|
12
12
|
* inspected is exactly what the next commit records (Decision 5). The
|
|
@@ -34,10 +34,11 @@ export async function stageGuardedChanges(input, deps) {
|
|
|
34
34
|
}
|
|
35
35
|
}
|
|
36
36
|
// --- Step 3: unstage the violations in one call, in git's order ---
|
|
37
|
+
const excluded = violations.map((violation) => violation.path);
|
|
37
38
|
if (violations.length > 0) {
|
|
38
39
|
// reset also clears unmerged entries, which git add has already turned
|
|
39
40
|
// into stage-0 marker text; the bytes stay on disk for the person.
|
|
40
|
-
await deps.writer.unstage(
|
|
41
|
+
await deps.writer.unstage(excluded);
|
|
41
42
|
for (const violation of violations) {
|
|
42
43
|
deps.logger.log({
|
|
43
44
|
level: 'WARN',
|
|
@@ -47,6 +48,6 @@ export async function stageGuardedChanges(input, deps) {
|
|
|
47
48
|
});
|
|
48
49
|
}
|
|
49
50
|
}
|
|
50
|
-
// --- Step 4: report whether anything is left to commit ---
|
|
51
|
-
return { staged: await deps.git.hasStagedChanges() };
|
|
51
|
+
// --- Step 4: report whether anything is left to commit, and what was kept out ---
|
|
52
|
+
return { staged: await deps.git.hasStagedChanges(), excluded };
|
|
52
53
|
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
export interface CommitMessageCommandInput {
|
|
2
|
+
/** commitMessageCommand, run by the shell in the repository root. */
|
|
3
|
+
readonly command: string;
|
|
4
|
+
/** Written to the command's stdin, then stdin is closed. */
|
|
5
|
+
readonly stdin: Buffer;
|
|
6
|
+
/** The commit's parent, as ENMETU_PARENT. */
|
|
7
|
+
readonly parent: string;
|
|
8
|
+
/** The commit's tree, as ENMETU_TREE. */
|
|
9
|
+
readonly tree: string;
|
|
10
|
+
}
|
|
11
|
+
export type CommitMessageCommandRun =
|
|
12
|
+
/** Exit code 0; stdout up to the output cap. */
|
|
13
|
+
{
|
|
14
|
+
readonly kind: 'printed';
|
|
15
|
+
readonly stdout: string;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Not started, a non-zero exit, a signal, the time limit, or a stop
|
|
19
|
+
* request; detail says which, with the first line of stderr when there is one.
|
|
20
|
+
*/
|
|
21
|
+
| {
|
|
22
|
+
readonly kind: 'failed';
|
|
23
|
+
readonly detail: string;
|
|
24
|
+
};
|
|
25
|
+
/**
|
|
26
|
+
* Runs commitMessageCommand for one commit (design 14.18): a shell command
|
|
27
|
+
* leading its own process group, ended with the whole group at the time
|
|
28
|
+
* limit or a stop request. Never throws.
|
|
29
|
+
*/
|
|
30
|
+
export interface ICommitMessageCommand {
|
|
31
|
+
run(input: CommitMessageCommandInput): Promise<CommitMessageCommandRun>;
|
|
32
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -1,5 +1,8 @@
|
|
|
1
|
-
|
|
1
|
+
import type { GitConfigEntry } from './config.js';
|
|
2
|
+
/** The git config questions enmetu asks; it cannot write configuration through it. */
|
|
2
3
|
export interface IGitConfigReader {
|
|
3
4
|
/** null when unset at every scope. */
|
|
4
5
|
configValue(key: string): Promise<string | null>;
|
|
6
|
+
/** Every enmetu.* entry at every scope, in git's reading order; none set: empty (design 14.18). */
|
|
7
|
+
enmetuEntries(): Promise<readonly GitConfigEntry[]>;
|
|
5
8
|
}
|
|
@@ -78,4 +78,9 @@ export interface IGitReader extends IGitConfigReader {
|
|
|
78
78
|
mergeBase(left: string, right: string): Promise<string | null>;
|
|
79
79
|
/** Root-relative paths that differ between two commits, a rename being a delete plus an add, in git's order. */
|
|
80
80
|
changedPaths(from: string, to: string): Promise<readonly string[]>;
|
|
81
|
+
/**
|
|
82
|
+
* `git diff-tree -p` from a commit to a tree, without colour, external
|
|
83
|
+
* diffs or textconv; the first maxBytes bytes, the rest dropped (design 14.18).
|
|
84
|
+
*/
|
|
85
|
+
patch(from: string, toTree: string, maxBytes: number): Promise<Buffer>;
|
|
81
86
|
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
export interface HostMessageInput {
|
|
2
|
+
readonly parent: string;
|
|
3
|
+
readonly tree: string;
|
|
4
|
+
/** The person's command for this change, its message or null; see CommitChange.runCommand. */
|
|
5
|
+
readonly runCommand: () => Promise<string | null>;
|
|
6
|
+
}
|
|
7
|
+
export type HostMessageRun =
|
|
8
|
+
/** The writer returned text; enmetu still checks that it holds a subject. */
|
|
9
|
+
{
|
|
10
|
+
readonly kind: 'wrote';
|
|
11
|
+
readonly text: string;
|
|
12
|
+
}
|
|
13
|
+
/** The writer returned null: commitSubject, nothing logged. */
|
|
14
|
+
| {
|
|
15
|
+
readonly kind: 'declined';
|
|
16
|
+
}
|
|
17
|
+
/** A throw, the time limit, a stop request or a value that is neither text nor null. */
|
|
18
|
+
| {
|
|
19
|
+
readonly kind: 'failed';
|
|
20
|
+
readonly detail: string;
|
|
21
|
+
};
|
|
22
|
+
/**
|
|
23
|
+
* The host program's commit message writer (EnmetuStartOptions.commitMessage)
|
|
24
|
+
* with its time limit, behind one call that never throws (design 14.19).
|
|
25
|
+
*/
|
|
26
|
+
export interface IHostMessageWriter {
|
|
27
|
+
write(input: HostMessageInput): Promise<HostMessageRun>;
|
|
28
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|