@fluado/enmetu 0.4.0 → 0.5.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 +19 -17
- package/dist/adapters/git/gitFacade.d.ts +3 -6
- package/dist/adapters/git/gitFacade.js +6 -2
- package/dist/adapters/git/gitProcessAdapter.js +2 -1
- package/dist/cli/commands/runCommand.d.ts +25 -3
- package/dist/cli/commands/runCommand.js +43 -6
- package/dist/cli/exitCodes.js +1 -0
- package/dist/cli.js +12 -6
- package/dist/contracts/IGitRunner.d.ts +7 -0
- package/dist/contracts/ISyncCycle.d.ts +8 -0
- package/dist/contracts/errors.d.ts +1 -1
- package/dist/contracts/library.d.ts +1 -1
- package/dist/contracts/runtime.d.ts +26 -5
- package/dist/contracts/syncTypes.d.ts +4 -1
- package/dist/enmetuInstances.d.ts +3 -3
- package/dist/enmetuInstances.js +6 -4
- package/dist/hooks/hookScript.d.ts +6 -11
- package/dist/hooks/hookScript.js +35 -11
- package/dist/hooks/preCommitHookInstaller.d.ts +12 -10
- package/dist/hooks/preCommitHookInstaller.js +23 -16
- package/dist/index.js +8 -4
- package/dist/library/enmetuLibrary.js +2 -2
- package/dist/sync/syncCycleController.d.ts +4 -1
- package/dist/sync/syncCycleController.js +47 -4
- package/dist/sync/syncCycleRunner.js +5 -2
- package/package.json +3 -2
package/README.md
CHANGED
|
@@ -6,6 +6,8 @@
|
|
|
6
6
|
|
|
7
7
|
<p align="center"><sub>/enˈme.tu/, Esperanto for "commit!"</sub></p>
|
|
8
8
|
|
|
9
|
+
<p align="center"><a href="https://www.npmjs.com/package/@fluado/enmetu"><img src="https://img.shields.io/npm/v/@fluado/enmetu?label=release&color=blue" alt="latest release"></a></p>
|
|
10
|
+
|
|
9
11
|
Commits and syncs a git working tree automatically. Run it inside a repository: it commits after you stop editing, rebases onto the upstream, pushes, and pulls new commits.
|
|
10
12
|
|
|
11
13
|
## Why enmetu
|
|
@@ -17,42 +19,42 @@ Most auto-commit tools commit and push. enmetu also handles what goes wrong:
|
|
|
17
19
|
- **Plain git.** You and your agents keep using git. enmetu commits through its own index, waits up to 5 minutes on a partial staging, pauses during your merge or rebase, and never pulls over a file you saved since its last commit: it commits the save first, then pulls.
|
|
18
20
|
- **Halts.** A rewritten upstream, or a state it cannot verify, halts it. The halt survives restarts until you run `enmetu resume`.
|
|
19
21
|
- **Status.** `enmetu status` says whether your work is committed and pushed, and during a conflict prints the commands to end it.
|
|
20
|
-
- **
|
|
21
|
-
- **
|
|
22
|
+
- **Commit messages.** Can be generated by a script that gets the diff, e.g. with an LLM. Falls back to a default message.
|
|
23
|
+
- **Dependencies.** None besides git. Runs as an executable, under Node or Bun, or as a library.
|
|
24
|
+
|
|
25
|
+
All of it is tested with real git against [35 conflict shapes](https://codefloe.com/fluado/enmetu/src/branch/main/src/__tests__/conflictMatrix.scenario.integration.test.ts) (renames against deletions, binary files, case-only renames, line endings, a merge in progress), [several clones converging on one history](https://codefloe.com/fluado/enmetu/src/branch/main/src/__tests__/convergence.scenario.integration.test.ts), and [a stress test](https://codefloe.com/fluado/enmetu/src/branch/main/src/__tests__/concurrencyStress.scenario.integration.test.ts) where a scripted person runs git next to enmetu as fast as it can, with every write checked against the final history.
|
|
22
26
|
|
|
23
27
|
## Prerequisites
|
|
24
28
|
|
|
25
|
-
- Node 24+ or Bun 1.4.4+
|
|
26
|
-
- git 2.
|
|
29
|
+
- The standalone executable, or Node 24+ or Bun 1.4.4+
|
|
30
|
+
- git 2.39+
|
|
27
31
|
- A checked-out branch with an upstream, at least one commit, and a committer identity
|
|
28
32
|
|
|
29
33
|
## Install
|
|
30
34
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
Inside a repository:
|
|
35
|
+
Executable, macOS or Linux:
|
|
34
36
|
|
|
35
37
|
```sh
|
|
36
|
-
|
|
38
|
+
curl -fsSL https://codefloe.com/fluado/enmetu/raw/branch/main/install.sh | sh
|
|
37
39
|
```
|
|
38
40
|
|
|
39
|
-
|
|
41
|
+
Node:
|
|
40
42
|
|
|
41
|
-
|
|
43
|
+
```sh
|
|
44
|
+
npx @fluado/enmetu run
|
|
45
|
+
```
|
|
42
46
|
|
|
43
|
-
|
|
47
|
+
Bun:
|
|
44
48
|
|
|
45
49
|
```sh
|
|
46
50
|
bunx --bun @fluado/enmetu run
|
|
47
51
|
```
|
|
48
52
|
|
|
49
|
-
As a project dependency: `bun add -d @fluado/enmetu`, then `bunx --bun enmetu run`.
|
|
50
|
-
|
|
51
53
|
Keep `--bun`: without it, `bunx` runs enmetu with Node when Node is installed.
|
|
52
54
|
|
|
53
|
-
|
|
55
|
+
As a dependency: `npm install -D @fluado/enmetu` or `bun add -d @fluado/enmetu`, then `npx enmetu run` or `bunx --bun enmetu run`.
|
|
54
56
|
|
|
55
|
-
|
|
57
|
+
Pin a version with `@fluado/enmetu@0.5.0`, or `ENMETU_VERSION=v0.5.0` for the installer. The binaries are also on the [releases page](https://codefloe.com/fluado/enmetu/releases).
|
|
56
58
|
|
|
57
59
|
## Commands
|
|
58
60
|
|
|
@@ -94,7 +96,7 @@ startEnmetu({
|
|
|
94
96
|
})
|
|
95
97
|
```
|
|
96
98
|
|
|
97
|
-
- `git diff-tree -r <parent> <tree>` in `root` lists the change.
|
|
99
|
+
- `git diff-tree -r -M <parent> <tree>` in `root` lists the change.
|
|
98
100
|
- Return the message, its first line the subject, or `null` for `commitSubject`.
|
|
99
101
|
- `runCommand()` runs the person's `commitMessageCommand` on this change, at most once, and gives its message or `null`. With `commitMessage` set, the command runs only through this call.
|
|
100
102
|
- 10 seconds, then `signal` aborts and enmetu commits with `commitSubject`. So does a throw, or text without a subject; the log says `MESSAGE_WRITER_FAILED`.
|
|
@@ -247,7 +249,7 @@ Overlapping git commands:
|
|
|
247
249
|
| `COMMIT_ORPHANED` | committed during enmetu's pull | `git cherry-pick -x` command in `enmetu status` |
|
|
248
250
|
| `STAGED_REVERT` | a refused `git merge` restored old content | `git restore` command in the log line |
|
|
249
251
|
| `could not write index` on a refused merge, or with older git (2.39) your edits gone after `would be overwritten by merge` | git stashed your edits | `git fsck --no-reflogs --unreachable`, then `git stash apply <id>` |
|
|
250
|
-
| `git rebase` fails, no rebase in progress | started together with enmetu's | `git rebase --quit` if needed, `git reset --merge
|
|
252
|
+
| `git rebase` fails, no rebase in progress | started together with enmetu's | `git rebase --quit` if needed, `git reset --merge` if files are unmerged, `git checkout -m <branch>` (keeps your edits), retry |
|
|
251
253
|
|
|
252
254
|
## Contributing
|
|
253
255
|
|
|
@@ -9,11 +9,6 @@ import type { IGitWriter } from '../../contracts/IGitWriter.js';
|
|
|
9
9
|
export declare const GIT_BATCH_CHUNK_BYTES = 67108864;
|
|
10
10
|
/** A hook's stdout and stderr beyond this are dropped, never a failure (design 12.7, Decision 83). */
|
|
11
11
|
export declare const HOOK_OUTPUT_CAP_BYTES = 65536;
|
|
12
|
-
/**
|
|
13
|
-
* Facade: typed questions and commands over the git CLI, with exactly the
|
|
14
|
-
* argv and interpretations of design section 1.3. gitDir locates the state
|
|
15
|
-
* files repositoryState and the rebase readers check.
|
|
16
|
-
*/
|
|
17
12
|
export declare class GitFacade implements IGitReader, IGitWriter, IGitCommitter {
|
|
18
13
|
private readonly runner;
|
|
19
14
|
private readonly fs;
|
|
@@ -135,7 +130,9 @@ export declare class GitFacade implements IGitReader, IGitWriter, IGitCommitter
|
|
|
135
130
|
* Runs a command that talks to the remote. A timeout there is the network or
|
|
136
131
|
* a credential prompt (ssh connect, passphrase, pinentry), so it comes back as
|
|
137
132
|
* a value the cycle reports (OFFLINE, or PUSH_FAILED with UNPUSHED, D2),
|
|
138
|
-
* never an error that ends the cycle in CYCLE_FAILED.
|
|
133
|
+
* never an error that ends the cycle in CYCLE_FAILED. The command leads its
|
|
134
|
+
* own process group, so the timeout ends the ssh it started too (Decision
|
|
135
|
+
* 158); killed alone, git would leave ssh waiting on the dead connection.
|
|
139
136
|
*/
|
|
140
137
|
private remoteGit;
|
|
141
138
|
/** Runs git and expects exit code 0. */
|
|
@@ -54,6 +54,8 @@ const OPERATION_MARKERS = [
|
|
|
54
54
|
* argv and interpretations of design section 1.3. gitDir locates the state
|
|
55
55
|
* files repositoryState and the rebase readers check.
|
|
56
56
|
*/
|
|
57
|
+
/** How the three remote commands run (Decision 158). */
|
|
58
|
+
const REMOTE_RUN = { killGroup: true };
|
|
57
59
|
export class GitFacade {
|
|
58
60
|
runner;
|
|
59
61
|
fs;
|
|
@@ -653,11 +655,13 @@ export class GitFacade {
|
|
|
653
655
|
* Runs a command that talks to the remote. A timeout there is the network or
|
|
654
656
|
* a credential prompt (ssh connect, passphrase, pinentry), so it comes back as
|
|
655
657
|
* a value the cycle reports (OFFLINE, or PUSH_FAILED with UNPUSHED, D2),
|
|
656
|
-
* never an error that ends the cycle in CYCLE_FAILED.
|
|
658
|
+
* never an error that ends the cycle in CYCLE_FAILED. The command leads its
|
|
659
|
+
* own process group, so the timeout ends the ssh it started too (Decision
|
|
660
|
+
* 158); killed alone, git would leave ssh waiting on the dead connection.
|
|
657
661
|
*/
|
|
658
662
|
async remoteGit(args) {
|
|
659
663
|
try {
|
|
660
|
-
return { kind: 'ran', result: await this.git(args) };
|
|
664
|
+
return { kind: 'ran', result: await this.git(args, REMOTE_RUN) };
|
|
661
665
|
}
|
|
662
666
|
catch (error) {
|
|
663
667
|
if (error instanceof GitCommandError && error.code === 'GIT_TIMEOUT') {
|
|
@@ -40,7 +40,8 @@ export class GitProcessAdapter {
|
|
|
40
40
|
const env = callEnvironment(this.env, options?.reflogAction);
|
|
41
41
|
if (options?.timeoutMs !== undefined ||
|
|
42
42
|
options?.outputCapBytes !== undefined ||
|
|
43
|
-
options?.abort !== undefined
|
|
43
|
+
options?.abort !== undefined ||
|
|
44
|
+
options?.killGroup === true) {
|
|
44
45
|
return this.runInGroup(argv, env, options);
|
|
45
46
|
}
|
|
46
47
|
return new Promise((resolve, reject) => {
|
|
@@ -3,13 +3,20 @@ 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';
|
|
5
5
|
import type { ILogger } from '../../contracts/ILogger.js';
|
|
6
|
-
import type { IRepositoryLocator, RepositoryPaths } from '../../contracts/IRepositoryLocator.js';
|
|
6
|
+
import type { GitVersion, IRepositoryLocator, RepositoryPaths } from '../../contracts/IRepositoryLocator.js';
|
|
7
7
|
export interface RunCommandInput {
|
|
8
8
|
/** --repo, else the working directory. */
|
|
9
9
|
readonly startDir: string;
|
|
10
10
|
}
|
|
11
|
+
/** The JavaScript runtime enmetu runs on and its version (design 14.25). */
|
|
12
|
+
export interface Engine {
|
|
13
|
+
readonly kind: 'node' | 'bun';
|
|
14
|
+
readonly version: string;
|
|
15
|
+
}
|
|
11
16
|
export interface RunCommandDeps {
|
|
12
17
|
readonly locator: IRepositoryLocator;
|
|
18
|
+
/** Checked first: an engine below the floor exits 5 before any git call (Decision 160). */
|
|
19
|
+
readonly engine: Engine;
|
|
13
20
|
readonly fs: IFileSystem;
|
|
14
21
|
/** Every enmetu.* entry of git config for the resolved repository (design 14.18). */
|
|
15
22
|
readonly readGitConfig: (paths: RepositoryPaths) => Promise<readonly GitConfigEntry[]>;
|
|
@@ -20,15 +27,30 @@ export interface RunCommandDeps {
|
|
|
20
27
|
export interface RunCommandResult {
|
|
21
28
|
readonly exitCode: ExitCode;
|
|
22
29
|
}
|
|
23
|
-
/**
|
|
30
|
+
/**
|
|
31
|
+
* Minimum git version: 2.39, the oldest git the suites run on (Debian bookworm,
|
|
32
|
+
* Decision 156). The feature floor is 2.38, where --no-update-refs appeared
|
|
33
|
+
* (Decision 16); no older git has run the suites, so none is promised.
|
|
34
|
+
*/
|
|
24
35
|
export declare const MIN_GIT_VERSION: {
|
|
25
36
|
major: number;
|
|
26
37
|
minor: number;
|
|
27
38
|
};
|
|
39
|
+
/**
|
|
40
|
+
* Minimum engine versions (design 14.25, Decision 160): Node 24 is the
|
|
41
|
+
* package's engines field; Bun 1.4.4 is measured, 1.3.6 drops the watch event
|
|
42
|
+
* for a file git merge rewrites (Decision 128). Shown as the README states them.
|
|
43
|
+
*/
|
|
44
|
+
export declare const MIN_ENGINE_VERSION: Readonly<Record<Engine['kind'], {
|
|
45
|
+
readonly floor: GitVersion;
|
|
46
|
+
readonly shown: string;
|
|
47
|
+
}>>;
|
|
28
48
|
/**
|
|
29
49
|
* Controller:
|
|
50
|
+
* 0. The engine below its floor: NOT_ELIGIBLE (ENGINE_TOO_OLD), exit 5, before
|
|
51
|
+
* any git call (design 14.25); a version that does not parse passes.
|
|
30
52
|
* 1. locator.locate(startDir): not a repository, bare, or PATH_UNSUPPORTED log NOT_ELIGIBLE, exit 5.
|
|
31
|
-
* 2. Git older than 2.
|
|
53
|
+
* 2. Git older than 2.39: NOT_ELIGIBLE (GIT_TOO_OLD), exit 5.
|
|
32
54
|
* 3. parseConfig of <root>/enmetu.config.json and readGitConfig(paths), git
|
|
33
55
|
* config overriding the file (design 14.18): ConfigError logs
|
|
34
56
|
* CONFIG_INVALID naming the file or the git config key and scope, exit 2;
|
|
@@ -2,12 +2,27 @@ import { join } from 'node:path';
|
|
|
2
2
|
import { ConfigError, PreflightError, } from '../../contracts/errors.js';
|
|
3
3
|
import { EXIT } from '../../contracts/exitCode.js';
|
|
4
4
|
import { CONFIG_FILE_NAME, parseConfig } from '../../core/config/parseConfig.js';
|
|
5
|
-
/**
|
|
6
|
-
|
|
5
|
+
/**
|
|
6
|
+
* Minimum git version: 2.39, the oldest git the suites run on (Debian bookworm,
|
|
7
|
+
* Decision 156). The feature floor is 2.38, where --no-update-refs appeared
|
|
8
|
+
* (Decision 16); no older git has run the suites, so none is promised.
|
|
9
|
+
*/
|
|
10
|
+
export const MIN_GIT_VERSION = { major: 2, minor: 39 };
|
|
11
|
+
/**
|
|
12
|
+
* Minimum engine versions (design 14.25, Decision 160): Node 24 is the
|
|
13
|
+
* package's engines field; Bun 1.4.4 is measured, 1.3.6 drops the watch event
|
|
14
|
+
* for a file git merge rewrites (Decision 128). Shown as the README states them.
|
|
15
|
+
*/
|
|
16
|
+
export const MIN_ENGINE_VERSION = {
|
|
17
|
+
node: { floor: { major: 24, minor: 0, patch: 0 }, shown: '24' },
|
|
18
|
+
bun: { floor: { major: 1, minor: 4, patch: 4 }, shown: '1.4.4' },
|
|
19
|
+
};
|
|
7
20
|
/**
|
|
8
21
|
* Controller:
|
|
22
|
+
* 0. The engine below its floor: NOT_ELIGIBLE (ENGINE_TOO_OLD), exit 5, before
|
|
23
|
+
* any git call (design 14.25); a version that does not parse passes.
|
|
9
24
|
* 1. locator.locate(startDir): not a repository, bare, or PATH_UNSUPPORTED log NOT_ELIGIBLE, exit 5.
|
|
10
|
-
* 2. Git older than 2.
|
|
25
|
+
* 2. Git older than 2.39: NOT_ELIGIBLE (GIT_TOO_OLD), exit 5.
|
|
11
26
|
* 3. parseConfig of <root>/enmetu.config.json and readGitConfig(paths), git
|
|
12
27
|
* config overriding the file (design 14.18): ConfigError logs
|
|
13
28
|
* CONFIG_INVALID naming the file or the git config key and scope, exit 2;
|
|
@@ -18,6 +33,12 @@ export const MIN_GIT_VERSION = { major: 2, minor: 38 };
|
|
|
18
33
|
* 4. createDaemon(paths, config).run(), its exit code returned.
|
|
19
34
|
*/
|
|
20
35
|
export async function runCommand(input, deps) {
|
|
36
|
+
// --- Step 0: the engine floor (design 14.25) ---
|
|
37
|
+
const engineVersion = parseVersion(deps.engine.version);
|
|
38
|
+
const minimum = MIN_ENGINE_VERSION[deps.engine.kind];
|
|
39
|
+
if (engineVersion !== null && olderThan(engineVersion, minimum.floor)) {
|
|
40
|
+
return notEligible(deps, 'ENGINE_TOO_OLD', `${deps.engine.kind} ${deps.engine.version} is older than ${minimum.shown}`);
|
|
41
|
+
}
|
|
21
42
|
// --- Step 1: the repository root through git, not the start directory (D9) ---
|
|
22
43
|
let location;
|
|
23
44
|
try {
|
|
@@ -101,9 +122,25 @@ function logConfigSources(parsed, logger) {
|
|
|
101
122
|
}
|
|
102
123
|
}
|
|
103
124
|
function olderThanMinimum(version) {
|
|
104
|
-
return (version
|
|
105
|
-
|
|
106
|
-
|
|
125
|
+
return olderThan(version, { ...MIN_GIT_VERSION, patch: 0 });
|
|
126
|
+
}
|
|
127
|
+
function olderThan(version, floor) {
|
|
128
|
+
if (version.major !== floor.major)
|
|
129
|
+
return version.major < floor.major;
|
|
130
|
+
if (version.minor !== floor.minor)
|
|
131
|
+
return version.minor < floor.minor;
|
|
132
|
+
return version.patch < floor.patch;
|
|
133
|
+
}
|
|
134
|
+
/** `major.minor.patch` at the start of the text (a label may follow); null when absent. */
|
|
135
|
+
function parseVersion(text) {
|
|
136
|
+
const match = /^(\d+)\.(\d+)\.(\d+)/.exec(text);
|
|
137
|
+
if (match === null)
|
|
138
|
+
return null;
|
|
139
|
+
return {
|
|
140
|
+
major: Number(match[1]),
|
|
141
|
+
minor: Number(match[2]),
|
|
142
|
+
patch: Number(match[3]),
|
|
143
|
+
};
|
|
107
144
|
}
|
|
108
145
|
function notEligible(deps, code, message) {
|
|
109
146
|
deps.logger.log({
|
package/dist/cli/exitCodes.js
CHANGED
|
@@ -10,6 +10,7 @@ const EXIT_CODE_BY_ERROR = {
|
|
|
10
10
|
LOCK_UNREADABLE: EXIT.LOCKED,
|
|
11
11
|
GIT_NOT_FOUND: EXIT.NOT_ELIGIBLE,
|
|
12
12
|
GIT_TOO_OLD: EXIT.NOT_ELIGIBLE,
|
|
13
|
+
ENGINE_TOO_OLD: EXIT.NOT_ELIGIBLE,
|
|
13
14
|
NOT_A_REPOSITORY: EXIT.NOT_ELIGIBLE,
|
|
14
15
|
BARE_REPOSITORY: EXIT.NOT_ELIGIBLE,
|
|
15
16
|
PATH_UNSUPPORTED: EXIT.NOT_ELIGIBLE,
|
package/dist/cli.js
CHANGED
|
@@ -2,8 +2,9 @@
|
|
|
2
2
|
import { createRequire } from 'node:module';
|
|
3
3
|
import { main } from './cli/main.js';
|
|
4
4
|
/**
|
|
5
|
-
* Entry
|
|
6
|
-
* process.
|
|
5
|
+
* Entry of the package's CLI: with src/index.ts and src/standalone.ts one of
|
|
6
|
+
* the three files that read process.argv, process.env, process.cwd(),
|
|
7
|
+
* process.execPath, process.stdout and process.stderr and set
|
|
7
8
|
* process.exitCode. It gathers them into a Runtime and calls main.
|
|
8
9
|
*/
|
|
9
10
|
/** package.json sits next to dist/ and src/, one level up from this file in both. */
|
|
@@ -21,13 +22,18 @@ const runtime = {
|
|
|
21
22
|
argv: process.argv.slice(2),
|
|
22
23
|
env: process.env,
|
|
23
24
|
cwd: process.cwd(),
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
25
|
+
launch: {
|
|
26
|
+
kind: 'script',
|
|
27
|
+
interpreter: process.execPath,
|
|
28
|
+
// Node makes argv[1] absolute. Should it ever be missing, the hook
|
|
29
|
+
// records an empty path, finds no CLI there and lets commits through
|
|
30
|
+
// (fail open).
|
|
31
|
+
script: process.argv[1] ?? '',
|
|
32
|
+
},
|
|
28
33
|
version: packageVersion(),
|
|
29
34
|
platform: process.platform,
|
|
30
35
|
engine: process.versions.bun === undefined ? 'node' : 'bun',
|
|
36
|
+
engineVersion: process.versions.bun ?? process.versions.node,
|
|
31
37
|
stdout: process.stdout,
|
|
32
38
|
stderr: process.stderr,
|
|
33
39
|
};
|
|
@@ -20,6 +20,13 @@ export interface GitRunOptions {
|
|
|
20
20
|
* repository hook this way (design 12.7, Decision 92).
|
|
21
21
|
*/
|
|
22
22
|
readonly abort?: AbortSignal | undefined;
|
|
23
|
+
/**
|
|
24
|
+
* Runs git leading its own process group with the adapter's per-command
|
|
25
|
+
* timeout, so the timeout ends git and everything git started: the ssh of a
|
|
26
|
+
* remote command, which a signal to git alone would orphan on a dead
|
|
27
|
+
* connection (design 14.24, Decision 158). The three remote commands set it.
|
|
28
|
+
*/
|
|
29
|
+
readonly killGroup?: boolean | undefined;
|
|
23
30
|
/**
|
|
24
31
|
* Sets GIT_REFLOG_ACTION for this call only, so the entries git writes to
|
|
25
32
|
* HEAD's reflog carry this action instead of the command's name: enmetu's
|
|
@@ -7,6 +7,12 @@ export interface SyncCycleInput {
|
|
|
7
7
|
readonly commitMemory?: CommitMemory | undefined;
|
|
8
8
|
/** Consecutive cycles whose rebase git refused to start (Decision 82); absent is 0. */
|
|
9
9
|
readonly notStartedRebases?: number | undefined;
|
|
10
|
+
/**
|
|
11
|
+
* The previous cycle deferred the pull for a save made during a cycle
|
|
12
|
+
* (TREE_CHANGING); the pull waits until a cycle commits nothing (Decision
|
|
13
|
+
* 162). Absent is false.
|
|
14
|
+
*/
|
|
15
|
+
readonly pullDeferred?: boolean | undefined;
|
|
10
16
|
}
|
|
11
17
|
export interface SyncCycleResult {
|
|
12
18
|
readonly outcome: CycleOutcome;
|
|
@@ -16,6 +22,8 @@ export interface SyncCycleResult {
|
|
|
16
22
|
readonly commitMemory?: CommitMemory | undefined;
|
|
17
23
|
/** The count for the next cycle (Decision 82); absent is 0. */
|
|
18
24
|
readonly notStartedRebases?: number | undefined;
|
|
25
|
+
/** Whether the pull is still deferred for the next cycle (Decision 162); absent is false. */
|
|
26
|
+
readonly pullDeferred?: boolean | undefined;
|
|
19
27
|
}
|
|
20
28
|
/** One cycle with its deps bound: a closure over syncCycleController, built by the composition root. */
|
|
21
29
|
export interface ISyncCycle {
|
|
@@ -1,5 +1,5 @@
|
|
|
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' | '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';
|
|
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' | 'ENGINE_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
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';
|
|
@@ -29,7 +29,7 @@ export interface CommitChange {
|
|
|
29
29
|
readonly root: string;
|
|
30
30
|
/** The commit's only parent. */
|
|
31
31
|
readonly parent: string;
|
|
32
|
-
/** The commit's tree; `git diff-tree -r <parent> <tree>` lists the change. */
|
|
32
|
+
/** The commit's tree; `git diff-tree -r -M <parent> <tree>` lists the change. */
|
|
33
33
|
readonly tree: string;
|
|
34
34
|
/**
|
|
35
35
|
* The person's commitMessageCommand on this change: its message, or null
|
|
@@ -1,20 +1,41 @@
|
|
|
1
1
|
import type { ITextSink } from './ITextSink.js';
|
|
2
|
-
/**
|
|
2
|
+
/**
|
|
3
|
+
* How this enmetu is started again, recorded in the pre-commit hook (designs
|
|
4
|
+
* 1.7, 13.1, 14.22).
|
|
5
|
+
*/
|
|
6
|
+
export type Launch =
|
|
7
|
+
/**
|
|
8
|
+
* An interpreter and the CLI script: node or bun with dist/cli.js. The
|
|
9
|
+
* interpreter is process.execPath, absolute under Node, a bare name under a
|
|
10
|
+
* Bun started by name (13.1); the script is process.argv[1], which Node
|
|
11
|
+
* makes absolute.
|
|
12
|
+
*/
|
|
13
|
+
{
|
|
14
|
+
readonly kind: 'script';
|
|
15
|
+
readonly interpreter: string;
|
|
16
|
+
readonly script: string;
|
|
17
|
+
}
|
|
18
|
+
/** The standalone executable of design 14.22 (process.execPath): it runs its own hook check. */
|
|
19
|
+
| {
|
|
20
|
+
readonly kind: 'executable';
|
|
21
|
+
readonly path: string;
|
|
22
|
+
};
|
|
23
|
+
/** Everything the process gives enmetu, gathered once by an entry (src/cli.ts, src/index.ts, src/standalone.ts). */
|
|
3
24
|
export interface Runtime {
|
|
4
25
|
/** Arguments after the node binary and the script. */
|
|
5
26
|
readonly argv: readonly string[];
|
|
6
27
|
/** Base environment for every git child. */
|
|
7
28
|
readonly env: Readonly<Record<string, string | undefined>>;
|
|
8
29
|
readonly cwd: string;
|
|
9
|
-
/**
|
|
10
|
-
readonly
|
|
11
|
-
/** Absolute path of the CLI script (Node resolves argv[1]), recorded in the hook. */
|
|
12
|
-
readonly cliPath: string;
|
|
30
|
+
/** Recorded in the hook. */
|
|
31
|
+
readonly launch: Launch;
|
|
13
32
|
readonly version: string;
|
|
14
33
|
/** process.platform: 'linux', 'darwin', 'win32', ... */
|
|
15
34
|
readonly platform: string;
|
|
16
35
|
/** The JavaScript runtime enmetu runs on: 'bun' when process.versions.bun is set. */
|
|
17
36
|
readonly engine: 'node' | 'bun';
|
|
37
|
+
/** Its version: process.versions.bun under Bun, process.versions.node under Node (design 14.25). */
|
|
38
|
+
readonly engineVersion: string;
|
|
18
39
|
readonly stdout: ITextSink;
|
|
19
40
|
readonly stderr: ITextSink;
|
|
20
41
|
}
|
|
@@ -42,8 +42,11 @@ export type CycleOutcome = {
|
|
|
42
42
|
readonly reason: PauseReason;
|
|
43
43
|
} | {
|
|
44
44
|
readonly kind: 'busy';
|
|
45
|
-
}
|
|
45
|
+
}
|
|
46
|
+
/** The remote could not be reached; detail is git's first line of reason (design 14.24, Decision 159). */
|
|
47
|
+
| {
|
|
46
48
|
readonly kind: 'offline';
|
|
49
|
+
readonly detail: string;
|
|
47
50
|
} | {
|
|
48
51
|
readonly kind: 'conflict';
|
|
49
52
|
} | {
|
|
@@ -31,9 +31,9 @@ export declare function createRecoveryDeps(paths: RepositoryPaths, config: Enmet
|
|
|
31
31
|
*/
|
|
32
32
|
export declare function createDaemon(paths: RepositoryPaths, config: EnmetuConfig, runtime: Runtime, processHost?: IProcessHost | undefined, commitMessage?: CommitMessageWriter | undefined): IDaemon;
|
|
33
33
|
/**
|
|
34
|
-
* The daemon's hook installer for one repository (
|
|
35
|
-
* Runtime.
|
|
36
|
-
* is a bare name
|
|
34
|
+
* The daemon's hook installer for one repository (designs 13.1, 14.22): it
|
|
35
|
+
* records Runtime.launch, its interpreter or executable resolved through the
|
|
36
|
+
* PATH enmetu was started with when it is a bare name.
|
|
37
37
|
*/
|
|
38
38
|
export declare function createHookInstaller(paths: RepositoryPaths, config: EnmetuConfig, runtime: Runtime): IHookInstaller;
|
|
39
39
|
export declare function createRunDeps(runtime: Runtime): RunCommandDeps;
|
package/dist/enmetuInstances.js
CHANGED
|
@@ -107,9 +107,9 @@ export function createDaemon(paths, config, runtime, processHost, commitMessage)
|
|
|
107
107
|
return new Daemon({ root: paths.root, quietMs: config.quietMs, pollMs: config.pollMs }, lockFor(paths, parts.fs, host, parts.clock), parts.halt, recovery, preflight, hookInstaller, watcherFor(paths.root, runtime), scheduler, host, parts.logger, state, parts.clock);
|
|
108
108
|
}
|
|
109
109
|
/**
|
|
110
|
-
* The daemon's hook installer for one repository (
|
|
111
|
-
* Runtime.
|
|
112
|
-
* is a bare name
|
|
110
|
+
* The daemon's hook installer for one repository (designs 13.1, 14.22): it
|
|
111
|
+
* records Runtime.launch, its interpreter or executable resolved through the
|
|
112
|
+
* PATH enmetu was started with when it is a bare name.
|
|
113
113
|
*/
|
|
114
114
|
export function createHookInstaller(paths, config, runtime) {
|
|
115
115
|
return hookInstallerOf(guardedRepositoryParts(paths, config.gitTimeoutMs, runtime), paths, config, runtime);
|
|
@@ -117,7 +117,7 @@ export function createHookInstaller(paths, config, runtime) {
|
|
|
117
117
|
// Null Object: the daemon has no branch for installHook (section 2).
|
|
118
118
|
function hookInstallerOf(parts, paths, config, runtime) {
|
|
119
119
|
return config.installHook
|
|
120
|
-
? new PreCommitHookInstaller(parts.git, parts.fs, paths.hooksDir,
|
|
120
|
+
? new PreCommitHookInstaller(parts.git, parts.fs, paths.hooksDir, runtime.launch, runtime.env.PATH)
|
|
121
121
|
: new NullHookInstaller();
|
|
122
122
|
}
|
|
123
123
|
export function createRunDeps(runtime) {
|
|
@@ -127,6 +127,7 @@ export function createRunDeps(runtime) {
|
|
|
127
127
|
fs,
|
|
128
128
|
readGitConfig: gitConfigReaderFor(runtime, fs),
|
|
129
129
|
logger: new StderrLoggerAdapter(runtime.stderr, new SystemClockAdapter()),
|
|
130
|
+
engine: { kind: runtime.engine, version: runtime.engineVersion },
|
|
130
131
|
createDaemon: (paths, config) => createDaemon(paths, config, runtime),
|
|
131
132
|
};
|
|
132
133
|
}
|
|
@@ -351,6 +352,7 @@ export function createEmbeddedRunDeps(runtime, commitMessage) {
|
|
|
351
352
|
fs,
|
|
352
353
|
readGitConfig: gitConfigReaderFor(runtime, fs),
|
|
353
354
|
logger: new StderrLoggerAdapter(runtime.stderr, new SystemClockAdapter()),
|
|
355
|
+
engine: { kind: runtime.engine, version: runtime.engineVersion },
|
|
354
356
|
createDaemon: (paths, config) => createDaemon(paths, config, runtime, processHost, commitMessage),
|
|
355
357
|
},
|
|
356
358
|
requestStop: () => processHost.requestStop(),
|
|
@@ -1,15 +1,10 @@
|
|
|
1
|
+
import type { Launch } from '../contracts/runtime.js';
|
|
1
2
|
export declare const HOOK_MARKER = "# enmetu-managed-hook v1";
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
readonly nodePath: string;
|
|
8
|
-
/** Absolute enmetu CLI script recorded at install time. */
|
|
9
|
-
readonly cliPath: string;
|
|
10
|
-
}
|
|
11
|
-
/** The POSIX sh pre-commit hook of design 1.7; a single quote becomes '\''. */
|
|
12
|
-
export declare function renderHookScript(paths: HookScriptPaths): string;
|
|
3
|
+
/**
|
|
4
|
+
* The POSIX sh pre-commit hook of design 1.7 in the shape the launch needs
|
|
5
|
+
* (design 14.22); a single quote becomes '\''.
|
|
6
|
+
*/
|
|
7
|
+
export declare function renderHookScript(launch: Launch): string;
|
|
13
8
|
/** true when the content carries the enmetu marker line. */
|
|
14
9
|
export declare function isEnmetuHook(content: string): boolean;
|
|
15
10
|
/**
|
package/dist/hooks/hookScript.js
CHANGED
|
@@ -1,14 +1,24 @@
|
|
|
1
1
|
export const HOOK_MARKER = '# enmetu-managed-hook v1';
|
|
2
|
-
|
|
3
|
-
|
|
2
|
+
const HEADER = [
|
|
3
|
+
'#!/bin/sh',
|
|
4
|
+
HOOK_MARKER,
|
|
5
|
+
'# Written by enmetu at startup; set "installHook": false in enmetu.config.json to stop that.',
|
|
6
|
+
'# Refuses a commit that records conflict markers or a .json file that does not parse.',
|
|
7
|
+
'# To commit anyway: git commit --no-verify',
|
|
8
|
+
];
|
|
9
|
+
/**
|
|
10
|
+
* The POSIX sh pre-commit hook of design 1.7 in the shape the launch needs
|
|
11
|
+
* (design 14.22); a single quote becomes '\''.
|
|
12
|
+
*/
|
|
13
|
+
export function renderHookScript(launch) {
|
|
14
|
+
const body = launch.kind === 'script' ? scriptForm(launch) : executableForm(launch);
|
|
15
|
+
return [...HEADER, ...body, ''].join('\n');
|
|
16
|
+
}
|
|
17
|
+
/** The interpreter and the CLI script, as src/cli.ts and src/index.ts record them. */
|
|
18
|
+
function scriptForm(launch) {
|
|
4
19
|
return [
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
'# Written by enmetu at startup; set "installHook": false in enmetu.config.json to stop that.',
|
|
8
|
-
'# Refuses a commit that records conflict markers or a .json file that does not parse.',
|
|
9
|
-
'# To commit anyway: git commit --no-verify',
|
|
10
|
-
`ENMETU_NODE=${shellQuote(paths.nodePath)}`,
|
|
11
|
-
`ENMETU_CLI=${shellQuote(paths.cliPath)}`,
|
|
20
|
+
`ENMETU_NODE=${shellQuote(launch.interpreter)}`,
|
|
21
|
+
`ENMETU_CLI=${shellQuote(launch.script)}`,
|
|
12
22
|
// The recorded interpreter may vanish with an upgrade (nvm, a moved Bun)
|
|
13
23
|
// or be a bare name no PATH entry held at install time: the same name on
|
|
14
24
|
// the commit's PATH keeps a Bun-only machine checking, node on PATH is
|
|
@@ -26,8 +36,22 @@ export function renderHookScript(paths) {
|
|
|
26
36
|
'fi',
|
|
27
37
|
'echo "enmetu: pre-commit check skipped (node or enmetu not found)" >&2',
|
|
28
38
|
'exit 0',
|
|
29
|
-
|
|
30
|
-
|
|
39
|
+
];
|
|
40
|
+
}
|
|
41
|
+
/** The standalone executable, which runs its own check (design 14.22, Decision 151). */
|
|
42
|
+
function executableForm(launch) {
|
|
43
|
+
return [
|
|
44
|
+
`ENMETU_BIN=${shellQuote(launch.path)}`,
|
|
45
|
+
// The recorded file may be gone (reinstalled elsewhere, another clone of
|
|
46
|
+
// the hook's machine): the same name on the commit's PATH, then enmetu on
|
|
47
|
+
// PATH, then fail open, the shape of Decision 102's line.
|
|
48
|
+
'[ -x "$ENMETU_BIN" ] || ENMETU_BIN="$(command -v "${ENMETU_BIN##*/}" 2>/dev/null || command -v enmetu 2>/dev/null)"',
|
|
49
|
+
'if [ -n "$ENMETU_BIN" ]; then',
|
|
50
|
+
' exec "$ENMETU_BIN" hook pre-commit',
|
|
51
|
+
'fi',
|
|
52
|
+
'echo "enmetu: pre-commit check skipped (enmetu not found)" >&2',
|
|
53
|
+
'exit 0',
|
|
54
|
+
];
|
|
31
55
|
}
|
|
32
56
|
/** true when the content carries the enmetu marker line. */
|
|
33
57
|
export function isEnmetuHook(content) {
|
|
@@ -1,32 +1,34 @@
|
|
|
1
1
|
import type { IFileSystem } from '../contracts/IFileSystem.js';
|
|
2
2
|
import type { IGitConfigReader } from '../contracts/IGitConfigReader.js';
|
|
3
3
|
import type { HookInstallResult, IHookInstaller } from '../contracts/IHookInstaller.js';
|
|
4
|
-
import {
|
|
4
|
+
import type { Launch } from '../contracts/runtime.js';
|
|
5
5
|
/**
|
|
6
6
|
* Service: installs <hooksDir>/pre-commit following rules 1 to 5 of design
|
|
7
7
|
* 1.7 (D10). Reads git configuration only through IGitConfigReader, so it
|
|
8
8
|
* cannot write any; never overwrites a hook without the enmetu marker.
|
|
9
9
|
* A bare-name interpreter (Bun started by name reports the bare name "bun"
|
|
10
|
-
* for its own executable path) is resolved through searchPath
|
|
11
|
-
* hook is rendered (
|
|
10
|
+
* for its own executable path) or executable is resolved through searchPath
|
|
11
|
+
* before the hook is rendered (designs 13.1 and 14.22, Decision 102).
|
|
12
12
|
*/
|
|
13
13
|
export declare class PreCommitHookInstaller implements IHookInstaller {
|
|
14
14
|
private readonly config;
|
|
15
15
|
private readonly fs;
|
|
16
16
|
private readonly hooksDir;
|
|
17
|
-
private readonly
|
|
17
|
+
private readonly launch;
|
|
18
18
|
/** The PATH the daemon was started with (Runtime.env.PATH); absent: no resolution. */
|
|
19
19
|
private readonly searchPath?;
|
|
20
|
-
constructor(config: IGitConfigReader, fs: IFileSystem, hooksDir: string,
|
|
20
|
+
constructor(config: IGitConfigReader, fs: IFileSystem, hooksDir: string, launch: Launch,
|
|
21
21
|
/** The PATH the daemon was started with (Runtime.env.PATH); absent: no resolution. */
|
|
22
22
|
searchPath?: string | undefined);
|
|
23
23
|
ensure(): Promise<HookInstallResult>;
|
|
24
24
|
private install;
|
|
25
|
+
/** The launch with its interpreter or executable resolved for the record. */
|
|
26
|
+
private resolvedLaunch;
|
|
25
27
|
/**
|
|
26
|
-
* The
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
28
|
+
* The file to record: an absolute name unchanged; for a bare name,
|
|
29
|
+
* <dir>/<name> for the first absolute search path entry where that is an
|
|
30
|
+
* executable file or a link; else the bare name, which the hook looks up
|
|
31
|
+
* on the commit's PATH.
|
|
30
32
|
*/
|
|
31
|
-
private
|
|
33
|
+
private resolve;
|
|
32
34
|
}
|
|
@@ -9,22 +9,22 @@ const OTHER_LANGUAGE_NOTE = 'A hook in another language runs "enmetu hook pre-co
|
|
|
9
9
|
* 1.7 (D10). Reads git configuration only through IGitConfigReader, so it
|
|
10
10
|
* cannot write any; never overwrites a hook without the enmetu marker.
|
|
11
11
|
* A bare-name interpreter (Bun started by name reports the bare name "bun"
|
|
12
|
-
* for its own executable path) is resolved through searchPath
|
|
13
|
-
* hook is rendered (
|
|
12
|
+
* for its own executable path) or executable is resolved through searchPath
|
|
13
|
+
* before the hook is rendered (designs 13.1 and 14.22, Decision 102).
|
|
14
14
|
*/
|
|
15
15
|
export class PreCommitHookInstaller {
|
|
16
16
|
config;
|
|
17
17
|
fs;
|
|
18
18
|
hooksDir;
|
|
19
|
-
|
|
19
|
+
launch;
|
|
20
20
|
searchPath;
|
|
21
|
-
constructor(config, fs, hooksDir,
|
|
21
|
+
constructor(config, fs, hooksDir, launch,
|
|
22
22
|
/** The PATH the daemon was started with (Runtime.env.PATH); absent: no resolution. */
|
|
23
23
|
searchPath) {
|
|
24
24
|
this.config = config;
|
|
25
25
|
this.fs = fs;
|
|
26
26
|
this.hooksDir = hooksDir;
|
|
27
|
-
this.
|
|
27
|
+
this.launch = launch;
|
|
28
28
|
this.searchPath = searchPath;
|
|
29
29
|
}
|
|
30
30
|
async ensure() {
|
|
@@ -73,11 +73,8 @@ export class PreCommitHookInstaller {
|
|
|
73
73
|
instruction: `${target} exists and is not enmetu's. To add enmetu's check, add this line to it: ${pathInvocationLine()}. ${OTHER_LANGUAGE_NOTE}`,
|
|
74
74
|
};
|
|
75
75
|
}
|
|
76
|
-
// 4. An enmetu hook is rewritten only when the recorded
|
|
77
|
-
const rendered = renderHookScript(
|
|
78
|
-
nodePath: await this.interpreterPath(),
|
|
79
|
-
cliPath: this.script.cliPath,
|
|
80
|
-
});
|
|
76
|
+
// 4. An enmetu hook is rewritten only when the recorded launch changed.
|
|
77
|
+
const rendered = renderHookScript(await this.resolvedLaunch());
|
|
81
78
|
if (existing === rendered) {
|
|
82
79
|
return { kind: 'unchanged' };
|
|
83
80
|
}
|
|
@@ -89,14 +86,24 @@ export class PreCommitHookInstaller {
|
|
|
89
86
|
});
|
|
90
87
|
return { kind: existing === null ? 'installed' : 'updated' };
|
|
91
88
|
}
|
|
89
|
+
/** The launch with its interpreter or executable resolved for the record. */
|
|
90
|
+
async resolvedLaunch() {
|
|
91
|
+
if (this.launch.kind === 'script') {
|
|
92
|
+
return {
|
|
93
|
+
kind: 'script',
|
|
94
|
+
interpreter: await this.resolve(this.launch.interpreter),
|
|
95
|
+
script: this.launch.script,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
return { kind: 'executable', path: await this.resolve(this.launch.path) };
|
|
99
|
+
}
|
|
92
100
|
/**
|
|
93
|
-
* The
|
|
94
|
-
*
|
|
95
|
-
*
|
|
96
|
-
*
|
|
101
|
+
* The file to record: an absolute name unchanged; for a bare name,
|
|
102
|
+
* <dir>/<name> for the first absolute search path entry where that is an
|
|
103
|
+
* executable file or a link; else the bare name, which the hook looks up
|
|
104
|
+
* on the commit's PATH.
|
|
97
105
|
*/
|
|
98
|
-
async
|
|
99
|
-
const name = this.script.nodePath;
|
|
106
|
+
async resolve(name) {
|
|
100
107
|
if (name.includes('/') || this.searchPath === undefined) {
|
|
101
108
|
return name;
|
|
102
109
|
}
|
package/dist/index.js
CHANGED
|
@@ -16,13 +16,17 @@ function processRuntime() {
|
|
|
16
16
|
argv: [],
|
|
17
17
|
env: process.env,
|
|
18
18
|
cwd: process.cwd(),
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
19
|
+
launch: {
|
|
20
|
+
kind: 'script',
|
|
21
|
+
interpreter: process.execPath,
|
|
22
|
+
// The CLI ships next to this file; the hook records it like
|
|
23
|
+
// `enmetu run` records its own argv[1].
|
|
24
|
+
script: fileURLToPath(new URL('./cli.js', import.meta.url)),
|
|
25
|
+
},
|
|
23
26
|
version: packageVersion(),
|
|
24
27
|
platform: process.platform,
|
|
25
28
|
engine: process.versions.bun === undefined ? 'node' : 'bun',
|
|
29
|
+
engineVersion: process.versions.bun ?? process.versions.node,
|
|
26
30
|
stdout: process.stdout,
|
|
27
31
|
stderr: process.stderr,
|
|
28
32
|
};
|
|
@@ -39,11 +39,11 @@ function embeddedRuntime(options, runtime) {
|
|
|
39
39
|
argv: [],
|
|
40
40
|
env: options.env ?? runtime.env,
|
|
41
41
|
cwd: runtime.cwd,
|
|
42
|
-
|
|
43
|
-
cliPath: runtime.cliPath,
|
|
42
|
+
launch: runtime.launch,
|
|
44
43
|
version: runtime.version,
|
|
45
44
|
platform: runtime.platform,
|
|
46
45
|
engine: runtime.engine,
|
|
46
|
+
engineVersion: runtime.engineVersion,
|
|
47
47
|
stdout: runtime.stdout,
|
|
48
48
|
stderr: options.log ?? runtime.stderr,
|
|
49
49
|
};
|
|
@@ -57,7 +57,10 @@ export interface SyncCycleDeps {
|
|
|
57
57
|
* returned (Decision 84).
|
|
58
58
|
* deferred (a save made during the cycle is dirty): UNPUSHED reported,
|
|
59
59
|
* waiting TREE_CHANGING returned, the refusal count left as it is
|
|
60
|
-
* (Decision 147)
|
|
60
|
+
* (Decision 147), and pullDeferred carried to the next cycle: while it is
|
|
61
|
+
* carried, a cycle that committed reports UNPUSHED and returns waiting
|
|
62
|
+
* TREE_CHANGING before integrate; the first cycle that commits nothing
|
|
63
|
+
* pulls and clears it (Decision 162).
|
|
61
64
|
* not-started: the count of consecutive refusals (input.notStartedRebases)
|
|
62
65
|
* goes up by one; the first logs REBASE_NOT_STARTED with git's first stderr
|
|
63
66
|
* line; UNPUSHED is reported; the outcome is busy, or paused REBASE_REFUSED
|
|
@@ -43,7 +43,10 @@ export const REBASE_REFUSED_AFTER = 3;
|
|
|
43
43
|
* returned (Decision 84).
|
|
44
44
|
* deferred (a save made during the cycle is dirty): UNPUSHED reported,
|
|
45
45
|
* waiting TREE_CHANGING returned, the refusal count left as it is
|
|
46
|
-
* (Decision 147)
|
|
46
|
+
* (Decision 147), and pullDeferred carried to the next cycle: while it is
|
|
47
|
+
* carried, a cycle that committed reports UNPUSHED and returns waiting
|
|
48
|
+
* TREE_CHANGING before integrate; the first cycle that commits nothing
|
|
49
|
+
* pulls and clears it (Decision 162).
|
|
47
50
|
* not-started: the count of consecutive refusals (input.notStartedRebases)
|
|
48
51
|
* goes up by one; the first logs REBASE_NOT_STARTED with git's first stderr
|
|
49
52
|
* line; UNPUSHED is reported; the outcome is busy, or paused REBASE_REFUSED
|
|
@@ -63,6 +66,7 @@ export async function syncCycleController(input, deps) {
|
|
|
63
66
|
const carried = {
|
|
64
67
|
commitMemory: input.commitMemory,
|
|
65
68
|
notStartedRebases: input.notStartedRebases,
|
|
69
|
+
pullDeferred: input.pullDeferred,
|
|
66
70
|
};
|
|
67
71
|
const outcome = (cycleOutcome) => resultOf(cycleOutcome, null, carried);
|
|
68
72
|
try {
|
|
@@ -123,6 +127,9 @@ async function runCycle(input, deps, carried, outcome) {
|
|
|
123
127
|
// Which dirty paths the pull may set aside (design 14.21): after a clean
|
|
124
128
|
// status none, so a path dirty at step 9 is a save made during the cycle.
|
|
125
129
|
let hold = HOLD_NOTHING;
|
|
130
|
+
// Whether this cycle's commit step committed: with a deferred pull carried
|
|
131
|
+
// in, that defers the pull once more (Decision 162).
|
|
132
|
+
let committedThisCycle = false;
|
|
126
133
|
if ((await deps.git.status()).length > 0) {
|
|
127
134
|
// A person's merge, cherry-pick or revert that stopped after step 2 is in
|
|
128
135
|
// that status; the commit step would only find it under index.lock (Decision 52).
|
|
@@ -154,6 +161,7 @@ async function runCycle(input, deps, carried, outcome) {
|
|
|
154
161
|
}
|
|
155
162
|
if (committed.kind === 'committed') {
|
|
156
163
|
committedFrom = committed.memory.lastMove?.from ?? null;
|
|
164
|
+
committedThisCycle = true;
|
|
157
165
|
}
|
|
158
166
|
}
|
|
159
167
|
else if (carried.commitMemory !== undefined) {
|
|
@@ -172,7 +180,10 @@ async function runCycle(input, deps, carried, outcome) {
|
|
|
172
180
|
let verifiedSha = await deps.git.resolveCommit(verifiedRef);
|
|
173
181
|
if (remoteHead.kind === 'unreachable') {
|
|
174
182
|
await reportUnpushedAgainstLastKnown(trackingSha ?? verifiedSha, upstream, cycleId, deps);
|
|
175
|
-
return outcome({
|
|
183
|
+
return outcome({
|
|
184
|
+
kind: 'offline',
|
|
185
|
+
detail: offlineDetail(remoteHead.detail),
|
|
186
|
+
});
|
|
176
187
|
}
|
|
177
188
|
if (remoteHead.kind === 'absent') {
|
|
178
189
|
// A deleted remote branch is never recreated by enmetu, so commits pile
|
|
@@ -191,7 +202,7 @@ async function runCycle(input, deps, carried, outcome) {
|
|
|
191
202
|
const fetched = await deps.writer.fetch(upstream.remote, upstream.remoteRef, incomingRefFor(branchRef));
|
|
192
203
|
if (fetched.kind === 'failed') {
|
|
193
204
|
await reportUnpushedAgainstLastKnown(trackingSha ?? verifiedSha, upstream, cycleId, deps);
|
|
194
|
-
return outcome({ kind: 'offline' });
|
|
205
|
+
return outcome({ kind: 'offline', detail: offlineDetail(fetched.detail) });
|
|
195
206
|
}
|
|
196
207
|
}
|
|
197
208
|
// Checked even when the fetch was skipped: a person's own fetch may already
|
|
@@ -214,6 +225,11 @@ async function runCycle(input, deps, carried, outcome) {
|
|
|
214
225
|
// --- Step 9: integrate when behind ---
|
|
215
226
|
if (behind === 0) {
|
|
216
227
|
carried.notStartedRebases = resetCount(carried.notStartedRebases);
|
|
228
|
+
// A cycle that commits nothing ends a deferral; a committing one keeps
|
|
229
|
+
// it, the writer may still be at work, and pushes as before (Decision 162).
|
|
230
|
+
if (!committedThisCycle) {
|
|
231
|
+
carried.pullDeferred = resetFlag(carried.pullDeferred);
|
|
232
|
+
}
|
|
217
233
|
}
|
|
218
234
|
else {
|
|
219
235
|
// git rebase refuses an index that differs from HEAD (Decision 84). A
|
|
@@ -226,6 +242,16 @@ async function runCycle(input, deps, carried, outcome) {
|
|
|
226
242
|
reportUnpushed(ahead, upstream, cycleId, deps);
|
|
227
243
|
return outcome({ kind: 'waiting', reason: waiting });
|
|
228
244
|
}
|
|
245
|
+
// After a deferral for a save made during a cycle, a cycle that commits
|
|
246
|
+
// defers again; the pull runs in the first cycle whose commit step finds
|
|
247
|
+
// nothing new, as the WAITING line says (Decision 162). A check at one
|
|
248
|
+
// instant is not enough: a file saved every 40 ms is clean right after
|
|
249
|
+
// each commit, and a rebase started then walks it through every replayed
|
|
250
|
+
// version (CodeFloe run 1, 2026-11-08).
|
|
251
|
+
if (carried.pullDeferred === true && committedThisCycle) {
|
|
252
|
+
reportUnpushed(ahead, upstream, cycleId, deps);
|
|
253
|
+
return outcome({ kind: 'waiting', reason: 'TREE_CHANGING' });
|
|
254
|
+
}
|
|
229
255
|
const pulledFrom = committedFrom ?? (await deps.git.headCommit());
|
|
230
256
|
const integrated = await deps.integrate({
|
|
231
257
|
cycleId,
|
|
@@ -236,10 +262,13 @@ async function runCycle(input, deps, carried, outcome) {
|
|
|
236
262
|
hold,
|
|
237
263
|
});
|
|
238
264
|
if (integrated.kind === 'deferred') {
|
|
239
|
-
// The next cycle commits the save first
|
|
265
|
+
// The next cycle commits the save first and pulls only once a cycle
|
|
266
|
+
// commits nothing (Decision 162); the runner logs WAITING once.
|
|
267
|
+
carried.pullDeferred = true;
|
|
240
268
|
reportUnpushed(ahead, upstream, cycleId, deps);
|
|
241
269
|
return outcome({ kind: 'waiting', reason: 'TREE_CHANGING' });
|
|
242
270
|
}
|
|
271
|
+
carried.pullDeferred = resetFlag(carried.pullDeferred);
|
|
243
272
|
if (integrated.kind === 'not-started') {
|
|
244
273
|
const refusals = (carried.notStartedRebases ?? 0) + 1;
|
|
245
274
|
carried.notStartedRebases = refusals;
|
|
@@ -318,8 +347,13 @@ function resultOf(cycleOutcome, conflict, carried) {
|
|
|
318
347
|
conflict,
|
|
319
348
|
commitMemory: carried.commitMemory,
|
|
320
349
|
notStartedRebases: carried.notStartedRebases,
|
|
350
|
+
pullDeferred: carried.pullDeferred,
|
|
321
351
|
};
|
|
322
352
|
}
|
|
353
|
+
/** A flag the input carried goes back to false; an absent one stays absent, which means false. */
|
|
354
|
+
function resetFlag(flag) {
|
|
355
|
+
return flag === undefined ? undefined : false;
|
|
356
|
+
}
|
|
323
357
|
/** A count the input carried goes back to 0; an absent one stays absent, which means 0. */
|
|
324
358
|
function resetCount(count) {
|
|
325
359
|
return count === undefined ? undefined : 0;
|
|
@@ -352,6 +386,15 @@ function reportUnpushed(ahead, upstream, cycleId, deps) {
|
|
|
352
386
|
* observation of the remote; the verified ref covers a tracking ref that
|
|
353
387
|
* `git fetch --prune` removed after the remote branch was deleted.
|
|
354
388
|
*/
|
|
389
|
+
/**
|
|
390
|
+
* What git said, for the OFFLINE line (Decision 159): its first line is ssh's
|
|
391
|
+
* "Permission denied (publickey)." or "Connection refused", or the adapter's
|
|
392
|
+
* timeout message; the rest is git's generic advice.
|
|
393
|
+
*/
|
|
394
|
+
function offlineDetail(detail) {
|
|
395
|
+
const line = firstLine(detail);
|
|
396
|
+
return line === '' ? 'no detail from git' : line;
|
|
397
|
+
}
|
|
355
398
|
async function reportUnpushedAgainstLastKnown(lastKnownSha, upstream, cycleId, deps) {
|
|
356
399
|
if (lastKnownSha === null) {
|
|
357
400
|
deps.logger.log({
|
|
@@ -34,10 +34,12 @@ export class SyncCycleRunner {
|
|
|
34
34
|
this.knownConflict = result.conflict;
|
|
35
35
|
if (this.carried !== null ||
|
|
36
36
|
result.commitMemory !== undefined ||
|
|
37
|
-
result.notStartedRebases !== undefined
|
|
37
|
+
result.notStartedRebases !== undefined ||
|
|
38
|
+
result.pullDeferred !== undefined) {
|
|
38
39
|
this.carried = {
|
|
39
40
|
commitMemory: result.commitMemory ?? EMPTY_COMMIT_MEMORY,
|
|
40
41
|
notStartedRebases: result.notStartedRebases ?? 0,
|
|
42
|
+
pullDeferred: result.pullDeferred ?? false,
|
|
41
43
|
};
|
|
42
44
|
}
|
|
43
45
|
this.reportQuietTransition(result.outcome, cycleId);
|
|
@@ -65,6 +67,7 @@ export class SyncCycleRunner {
|
|
|
65
67
|
knownConflict: this.knownConflict,
|
|
66
68
|
commitMemory: this.carried.commitMemory,
|
|
67
69
|
notStartedRebases: this.carried.notStartedRebases,
|
|
70
|
+
pullDeferred: this.carried.pullDeferred,
|
|
68
71
|
};
|
|
69
72
|
}
|
|
70
73
|
reportQuietTransition(outcome, cycleId) {
|
|
@@ -133,7 +136,7 @@ function quietEntry(outcome, cycleId) {
|
|
|
133
136
|
return {
|
|
134
137
|
level: 'WARN',
|
|
135
138
|
code: 'OFFLINE',
|
|
136
|
-
message:
|
|
139
|
+
message: `remote unreachable (${outcome.detail}); retrying on every poll`,
|
|
137
140
|
cycleId,
|
|
138
141
|
};
|
|
139
142
|
case 'waiting':
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fluado/enmetu",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "Keeps one git working tree committed and in sync with its upstream.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "fluado UG (haftungsbeschränkt)",
|
|
@@ -20,7 +20,8 @@
|
|
|
20
20
|
"dist"
|
|
21
21
|
],
|
|
22
22
|
"engines": {
|
|
23
|
-
"node": ">=24"
|
|
23
|
+
"node": ">=24",
|
|
24
|
+
"bun": ">=1.4.4"
|
|
24
25
|
},
|
|
25
26
|
"devDependencies": {
|
|
26
27
|
"@biomejs/biome": "2.3.8",
|