@fluado/enmetu 0.3.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.
Files changed (38) hide show
  1. package/README.md +26 -20
  2. package/dist/adapters/git/gitFacade.d.ts +3 -6
  3. package/dist/adapters/git/gitFacade.js +6 -2
  4. package/dist/adapters/git/gitProcessAdapter.js +2 -1
  5. package/dist/cli/commands/runCommand.d.ts +25 -3
  6. package/dist/cli/commands/runCommand.js +43 -6
  7. package/dist/cli/exitCodes.js +1 -0
  8. package/dist/cli.js +12 -6
  9. package/dist/commit/commitLocalChangesController.d.ts +15 -5
  10. package/dist/commit/commitLocalChangesController.js +21 -14
  11. package/dist/commit/stageGuardedChanges.d.ts +3 -1
  12. package/dist/commit/stageGuardedChanges.js +5 -4
  13. package/dist/contracts/IGitRunner.d.ts +7 -0
  14. package/dist/contracts/ISyncCycle.d.ts +8 -0
  15. package/dist/contracts/errors.d.ts +1 -1
  16. package/dist/contracts/library.d.ts +1 -1
  17. package/dist/contracts/logCodes.d.ts +1 -1
  18. package/dist/contracts/runtime.d.ts +26 -5
  19. package/dist/contracts/syncTypes.d.ts +27 -5
  20. package/dist/contracts/syncTypes.js +2 -0
  21. package/dist/core/quarantine/restoreWarnings.d.ts +6 -3
  22. package/dist/core/quarantine/restoreWarnings.js +12 -3
  23. package/dist/core/state/stateGuards.js +1 -0
  24. package/dist/core/status/statusHeadline.js +3 -0
  25. package/dist/enmetuInstances.d.ts +3 -3
  26. package/dist/enmetuInstances.js +6 -4
  27. package/dist/hooks/hookScript.d.ts +6 -11
  28. package/dist/hooks/hookScript.js +35 -11
  29. package/dist/hooks/preCommitHookInstaller.d.ts +12 -10
  30. package/dist/hooks/preCommitHookInstaller.js +23 -16
  31. package/dist/index.js +8 -4
  32. package/dist/library/enmetuLibrary.js +2 -2
  33. package/dist/sync/integrateUpstreamController.d.ts +16 -2
  34. package/dist/sync/integrateUpstreamController.js +21 -1
  35. package/dist/sync/syncCycleController.d.ts +9 -1
  36. package/dist/sync/syncCycleController.js +69 -4
  37. package/dist/sync/syncCycleRunner.js +6 -2
  38. 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
@@ -14,45 +16,45 @@ Most auto-commit tools commit and push. enmetu also handles what goes wrong:
14
16
 
15
17
  - **Conflicts.** A pull that conflicts is undone and held. Your commits stay local, and enmetu retries when either side changes. It never commits conflict markers and never force-pushes.
16
18
  - **No stash.** A file it won't commit (conflict markers, invalid JSON) stays on disk and doesn't block the rest of the sync. Its pre-commit hook applies the same check to your own commits.
17
- - **Plain git.** You and your agents keep using git. enmetu commits through its own index, waits up to 5 minutes on a partial staging, and pauses during your merge or rebase.
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
- - **Dependencies.** None besides git. Runs under Node or Bun, as a command or as a library.
21
- - **Tests.** Real git against [28 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
+ - **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.38+
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
- ### Node
32
-
33
- Inside a repository:
35
+ Executable, macOS or Linux:
34
36
 
35
37
  ```sh
36
- npx @fluado/enmetu run
38
+ curl -fsSL https://codefloe.com/fluado/enmetu/raw/branch/main/install.sh | sh
37
39
  ```
38
40
 
39
- As a project dependency: `npm install -D @fluado/enmetu`, then `npx enmetu run`.
41
+ Node:
40
42
 
41
- ### Bun
43
+ ```sh
44
+ npx @fluado/enmetu run
45
+ ```
42
46
 
43
- Inside a repository:
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
- ### Pinning
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
- Add the version for repeatable runs: `@fluado/enmetu@0.3.0`.
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`.
@@ -190,6 +192,7 @@ stderr, one line per event:
190
192
  | `EXCLUDED` | file not committed: conflict markers or invalid JSON |
191
193
  | `CONFLICT`, `UNPUSHED` | local commits not pushed |
192
194
  | `WAITING`, `PAUSED`, `OFFLINE` | sync on hold |
195
+ | `HELD`, `RESTORED` | a file enmetu could not commit was set aside for a pull and put back; see Known limitations |
193
196
  | `HALTED` | see `enmetu status` |
194
197
  | `MESSAGE_COMMAND_FAILED`, `MESSAGE_WRITER_FAILED` | message from the fallback named in the line |
195
198
  | `COMMIT_ORPHANED`, `STAGED_RESET`, `STAGED_REVERT`, `QUARANTINE_KEPT` | see Known limitations |
@@ -231,23 +234,26 @@ if command -v enmetu >/dev/null 2>&1; then enmetu hook pre-commit || exit 1; els
231
234
  - `commit-msg` and `prepare-commit-msg` hooks don't run on enmetu's commits; `commitMessageCommand` writes the message instead.
232
235
  - Under Bun without file-watching permission, enmetu commits on its poll interval.
233
236
  - Under Node on Linux, enmetu does not watch files: from Node 24.22, Node's recursive watch there can end the process ([nodejs/node#66791](https://github.com/nodejs/node/issues/66791)). enmetu commits on its poll interval (`pollMs`, 5 seconds by default) and says so once at start (`WATCHER_FAILED`). Bun watches.
234
- - A save during enmetu's pull can be overwritten by git without notice.
237
+ - A file written faster than a cycle runs keeps the pull waiting (`WAITING` with `TREE_CHANGING`) until it stops.
238
+ - A file enmetu cannot commit (invalid JSON, conflict markers, a rejected `pre-commit`) is set aside during a pull and shows the committed version from `HELD` until `RESTORED`, about 0.1 to 0.5 seconds. A save made meanwhile is merged back, or kept as a blob named in `QUARANTINE_KEPT` when it does not merge.
239
+ - A save landing in the milliseconds before git rewrites a file, inside a pull or its abort, is lost without a record.
235
240
 
236
241
  Overlapping git commands:
237
242
 
238
243
  | Symptom | Cause | Fix |
239
244
  |---|---|---|
245
+ | a file briefly showed old content | `HELD`: enmetu set it aside for a pull | wait for `RESTORED`; a `QUARANTINE_KEPT` line names a blob: `git cat-file -p <id>` |
240
246
  | `cannot lock ref 'HEAD'` | raced enmetu's commit | after `INDEX_REPAIRED` in the log: `git reset --merge`, retry. If `not uptodate`: `git merge --quit`, `git reset` |
241
247
  | `STAGED_RESET` | staged during enmetu's rebase | command in the log line |
242
248
  | `QUARANTINE_KEPT` | your edit collided with a version enmetu held aside | command in the log line |
243
249
  | `COMMIT_ORPHANED` | committed during enmetu's pull | `git cherry-pick -x` command in `enmetu status` |
244
250
  | `STAGED_REVERT` | a refused `git merge` restored old content | `git restore` command in the log line |
245
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>` |
246
- | `git rebase` fails, no rebase in progress | started together with enmetu's | `git rebase --quit` if needed, `git reset --merge`, `git checkout <branch>`, retry |
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 |
247
253
 
248
254
  ## Contributing
249
255
 
250
- We built enmetu for ourselves. We use it every day at [fluado](https://fluado.com) and we're sharing it because it might be useful to others.
256
+ We built enmetu for ourselves. We use it every day at [fluado](https://fluado.com) and we're sharing it because it might be useful to others. It is the sync engine of [faru](https://github.com/fluado/faru), our git-native kanban board.
251
257
 
252
258
  This is not a community project. We don't have the bandwidth to review PRs, triage issues, or maintain a roadmap for external contributors. If you open an issue with a good idea, we might pick it up when it aligns with what we need. No promises.
253
259
 
@@ -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
- /** Minimum git version (Decision 16): --no-update-refs appeared in 2.38. */
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.38: NOT_ELIGIBLE (GIT_TOO_OLD), exit 5.
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
- /** Minimum git version (Decision 16): --no-update-refs appeared in 2.38. */
6
- export const MIN_GIT_VERSION = { major: 2, minor: 38 };
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.38: NOT_ELIGIBLE (GIT_TOO_OLD), exit 5.
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.major < MIN_GIT_VERSION.major ||
105
- (version.major === MIN_GIT_VERSION.major &&
106
- version.minor < MIN_GIT_VERSION.minor));
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({
@@ -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: the only file that reads process.argv, process.env, process.cwd(),
6
- * process.execPath, process.stdout and process.stderr and sets
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
- execPath: process.execPath,
25
- // Node makes argv[1] absolute. Should it ever be missing, the hook records
26
- // an empty path, finds no CLI there and lets commits through (fail open).
27
- cliPath: process.argv[1] ?? '',
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
  };
@@ -7,7 +7,7 @@ import type { IGitReader } from '../contracts/IGitReader.js';
7
7
  import type { IGitWriter } from '../contracts/IGitWriter.js';
8
8
  import type { IHostMessageWriter } from '../contracts/IHostMessageWriter.js';
9
9
  import type { ILogger } from '../contracts/ILogger.js';
10
- import type { CommitMemory, WaitReason } from '../contracts/syncTypes.js';
10
+ import type { CommitMemory, HoldPolicy, WaitReason } from '../contracts/syncTypes.js';
11
11
  /** An unchanged tree a hook rejected is offered to the hooks again after this long (design 12.7). */
12
12
  export declare const HOOK_RETRY_MS = 600000;
13
13
  /** commitMessageCommand reads at most this much of the patch on stdin (design 14.18). */
@@ -66,11 +66,19 @@ export interface CommitLocalChangesDeps {
66
66
  readonly clock: IClock;
67
67
  readonly settings: CommitSettings;
68
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
+ */
69
75
  export type CommitLocalChangesResult = {
70
76
  readonly kind: 'nothing';
77
+ readonly hold: HoldPolicy;
71
78
  readonly memory: CommitMemory;
72
79
  } | {
73
80
  readonly kind: 'committed';
81
+ readonly hold: HoldPolicy;
74
82
  readonly memory: CommitMemory;
75
83
  } | {
76
84
  readonly kind: 'waiting';
@@ -108,15 +116,17 @@ export type CommitLocalChanges = (input: CommitLocalChangesInput) => Promise<Com
108
116
  * SELECTION_WAIT_ENDED and goes on; 0 goes on at once (design 14.17).
109
117
  * 2. Build: parent = git.headCommit(); fs.copy(realIndex, commit index);
110
118
  * committer.seedFrom(parent); stageGuardedChanges on the commit index.
111
- * Nothing staged returns nothing.
112
- * 3. tree = committer.writeTree(); equal to memory.residueTree returns nothing;
113
- * equal to memory.rejection.tree within HOOK_RETRY_MS returns waiting
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
114
123
  * COMMIT_HOOK_REJECTED without running a hook.
115
124
  * 4. commitHooks: pre-commit on the commit index (rejected, timed out or
116
125
  * killed: HOOK_REJECTED, waiting, rejection remembered); then re-stage and
117
126
  * write the tree again, so a formatter's rewrite is in this commit. The
118
127
  * message hooks do not run (Decision 80). The re-stage logs through
119
- * 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).
120
130
  * 4b. The message (design 14.18, 14.19), before index.lock, so no writer
121
131
  * ever holds up a person's git. The command's message: when
122
132
  * commitMessageCommand is set, messageCommand runs with
@@ -2,7 +2,7 @@ import { EnmetuError, GitCommandError } from '../contracts/errors.js';
2
2
  import { withLastMove } from '../core/commit/commitMemory.js';
3
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
8
  /** commitMessageCommand reads at most this much of the patch on stdin (design 14.18). */
@@ -23,15 +23,17 @@ export const MESSAGE_PATCH_CAP_BYTES = 65536;
23
23
  * SELECTION_WAIT_ENDED and goes on; 0 goes on at once (design 14.17).
24
24
  * 2. Build: parent = git.headCommit(); fs.copy(realIndex, commit index);
25
25
  * committer.seedFrom(parent); stageGuardedChanges on the commit index.
26
- * Nothing staged returns nothing.
27
- * 3. tree = committer.writeTree(); equal to memory.residueTree returns nothing;
28
- * equal to memory.rejection.tree within HOOK_RETRY_MS returns waiting
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
29
30
  * COMMIT_HOOK_REJECTED without running a hook.
30
31
  * 4. commitHooks: pre-commit on the commit index (rejected, timed out or
31
32
  * killed: HOOK_REJECTED, waiting, rejection remembered); then re-stage and
32
33
  * write the tree again, so a formatter's rewrite is in this commit. The
33
34
  * message hooks do not run (Decision 80). The re-stage logs through
34
- * 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).
35
37
  * 4b. The message (design 14.18, 14.19), before index.lock, so no writer
36
38
  * ever holds up a person's git. The command's message: when
37
39
  * commitMessageCommand is set, messageCommand runs with
@@ -111,13 +113,16 @@ export async function commitLocalChangesController(input, deps) {
111
113
  // The preflight refuses an unborn HEAD; this guards the type.
112
114
  throw new EnmetuError('HEAD is unborn; there is nothing to commit onto', 'HEAD_UNBORN', cycleId);
113
115
  }
114
- if (!(await build(parent, cycleId, deps.logger, deps))) {
115
- return { kind: 'nothing', memory: unchanged };
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 };
116
120
  }
117
121
  // --- Step 3: trees enmetu already knows ---
118
122
  let tree = await deps.commitIndex.committer.writeTree();
119
123
  if (tree === memory.residueTree) {
120
- return { kind: 'nothing', memory: unchanged };
124
+ // Every dirty path is what the hooks wrote after the last commit (Decision 90).
125
+ return { kind: 'nothing', hold: { kind: 'all' }, memory: unchanged };
121
126
  }
122
127
  const rejection = memory.rejection;
123
128
  if (rejection !== null &&
@@ -156,8 +161,9 @@ export async function commitLocalChangesController(input, deps) {
156
161
  logger: deps.residueLogger,
157
162
  skipJsonCheck: settings.skipJsonCheck,
158
163
  });
164
+ hold = { kind: 'only', paths: restaged.excluded };
159
165
  if (!restaged.staged) {
160
- return { kind: 'nothing', memory: unchanged };
166
+ return { kind: 'nothing', hold, memory: unchanged };
161
167
  }
162
168
  tree = await deps.commitIndex.committer.writeTree();
163
169
  }
@@ -192,6 +198,7 @@ export async function commitLocalChangesController(input, deps) {
192
198
  }
193
199
  return {
194
200
  kind: 'committed',
201
+ hold,
195
202
  memory: {
196
203
  partialStagingSince: null,
197
204
  rejection: null,
@@ -213,7 +220,7 @@ export async function commitLocalChangesController(input, deps) {
213
220
  async function runPostCommitHooks(commit, tree, cycleId, deps) {
214
221
  const mayWrite = (await deps.realHooks.hookExists('post-index-change')) ||
215
222
  (await deps.realHooks.hookExists('post-commit'));
216
- const pendingBefore = mayWrite && (await build(commit, cycleId, deps.residueLogger, deps));
223
+ const pendingBefore = mayWrite && (await build(commit, cycleId, deps.residueLogger, deps)).staged;
217
224
  // Both run through `git hook run --ignore-missing` whatever hookExists
218
225
  // said, so what runs is git's decision alone; git ignores both exit codes,
219
226
  // and so does enmetu.
@@ -222,7 +229,7 @@ async function runPostCommitHooks(commit, tree, cycleId, deps) {
222
229
  if (!mayWrite || pendingBefore) {
223
230
  return null;
224
231
  }
225
- if (!(await build(commit, cycleId, deps.residueLogger, deps))) {
232
+ if (!(await build(commit, cycleId, deps.residueLogger, deps)).staged) {
226
233
  return null;
227
234
  }
228
235
  const residue = await deps.commitIndex.committer.writeTree();
@@ -324,7 +331,8 @@ async function commandMessage(parent, tree, cycleId, deps) {
324
331
  /**
325
332
  * Step 2 of the chain: copy the real index (git's stat data and skip-worktree
326
333
  * bits, so add -A does not re-hash every file), seed it with the commit's
327
- * tree, stage the guarded working tree. true when anything is staged.
334
+ * tree, stage the guarded working tree; staged when anything is, with the
335
+ * paths the content rules kept out (design 14.21).
328
336
  */
329
337
  async function build(seed, cycleId, logger, deps) {
330
338
  const { commitIndex } = deps;
@@ -334,13 +342,12 @@ async function build(seed, cycleId, logger, deps) {
334
342
  await deps.fs.remove(commitIndex.path);
335
343
  }
336
344
  await commitIndex.committer.seedFrom(seed);
337
- const { staged } = await stageGuardedChanges({ cycleId }, {
345
+ return stageGuardedChanges({ cycleId }, {
338
346
  git: commitIndex.reader,
339
347
  writer: commitIndex.writer,
340
348
  logger,
341
349
  skipJsonCheck: deps.settings.skipJsonCheck,
342
350
  });
343
- return staged;
344
351
  }
345
352
  /**
346
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(violations.map((violation) => violation.path));
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
  }
@@ -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 +1 @@
1
- export type LogCode = 'STARTED' | 'STOPPING' | 'STOPPED' | 'LOCK_STALE_TAKEN' | 'RECOVERED' | 'HOOK_INSTALLED' | 'HOOK_UPDATED' | 'HOOK_SKIPPED' | 'HOOK_FAILED' | 'WATCHER_FAILED' | 'COMMITTED' | 'STAGE_PARTIAL' | 'EXCLUDED' | 'PULLED' | 'PUSHED' | 'PUSH_REJECTED' | 'PUSH_FAILED' | 'OFFLINE' | 'CONFLICT' | 'UNPUSHED' | 'PAUSED' | 'BUSY' | 'RESUMED' | 'QUARANTINE_KEPT' | 'HALTED' | 'CYCLE_FAILED' | 'NOT_ELIGIBLE' | 'LOCKED' | 'CONFIG_INVALID' | 'CONFIG_KEY_IGNORED' | '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';
1
+ export type LogCode = 'STARTED' | 'STOPPING' | 'STOPPED' | 'LOCK_STALE_TAKEN' | 'RECOVERED' | 'HOOK_INSTALLED' | 'HOOK_UPDATED' | 'HOOK_SKIPPED' | 'HOOK_FAILED' | 'WATCHER_FAILED' | 'COMMITTED' | 'STAGE_PARTIAL' | 'EXCLUDED' | 'PULLED' | 'PUSHED' | 'PUSH_REJECTED' | 'PUSH_FAILED' | 'OFFLINE' | 'CONFLICT' | 'UNPUSHED' | 'PAUSED' | 'BUSY' | 'RESUMED' | 'QUARANTINE_KEPT' | 'HALTED' | 'CYCLE_FAILED' | 'NOT_ELIGIBLE' | 'LOCKED' | 'CONFIG_INVALID' | 'CONFIG_KEY_IGNORED' | 'CONFIG_FROM_GIT' | 'MESSAGE_COMMAND_FAILED' | 'MESSAGE_WRITER_FAILED' | 'STATE_WRITE_FAILED' | 'WAITING' | 'SELECTION_WAIT_ENDED' | 'HOOK_REJECTED' | 'COMMIT_DISCARDED' | 'INDEX_REPAIRED' | 'QUARANTINE_UPSTREAM_CHANGED' | 'REBASE_NOT_STARTED' | 'STAGED_RESET' | 'STAGED_REVERT' | 'REBASE_BRANCH_MOVED' | 'COMMIT_ORPHANED' | 'HELD' | 'RESTORED';