@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
@@ -1,5 +1,5 @@
1
1
  import { EnmetuError } from '../contracts/errors.js';
2
- import { EMPTY_COMMIT_MEMORY, } from '../contracts/syncTypes.js';
2
+ import { EMPTY_COMMIT_MEMORY, HOLD_NOTHING, } from '../contracts/syncTypes.js';
3
3
  import { withLastMove } from '../core/commit/commitMemory.js';
4
4
  import { withCycleId } from '../core/errors/withCycleId.js';
5
5
  import { incomingRefFor, verifiedRefFor } from '../core/git/enmetuRefs.js';
@@ -28,7 +28,9 @@ export const REBASE_REFUSED_AFTER = 3;
28
28
  * memory (design 12.7); busy and paused return, committed and nothing go
29
29
  * on, waiting goes on and is the outcome unless a later step returns
30
30
  * something else. The check right before the commit is the commit step's,
31
- * under index.lock.
31
+ * under index.lock. The hold policy for step 9 (design 14.21): the commit
32
+ * step's after committed or nothing, all after a hook rejection, nothing
33
+ * after a clean status.
32
34
  * 6. Remote check: ls-remote, tracking ref, verified ref (seeded from tracking when missing).
33
35
  * 7. Verify and fetch: fetch into the incoming ref when the remote moved; the remote sha
34
36
  * must descend from the verified sha, else halt UPSTREAM_REWRITTEN; then move the
@@ -39,6 +41,12 @@ export const REBASE_REFUSED_AFTER = 3;
39
41
  * git.hasStagedChanges() says the real index differs from HEAD (git would
40
42
  * refuse the rebase), integrate is skipped, UNPUSHED reported and waiting
41
43
  * returned (Decision 84).
44
+ * deferred (a save made during the cycle is dirty): UNPUSHED reported,
45
+ * waiting TREE_CHANGING returned, the refusal count left as it is
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).
42
50
  * not-started: the count of consecutive refusals (input.notStartedRebases)
43
51
  * goes up by one; the first logs REBASE_NOT_STARTED with git's first stderr
44
52
  * line; UNPUSHED is reported; the outcome is busy, or paused REBASE_REFUSED
@@ -58,6 +66,7 @@ export async function syncCycleController(input, deps) {
58
66
  const carried = {
59
67
  commitMemory: input.commitMemory,
60
68
  notStartedRebases: input.notStartedRebases,
69
+ pullDeferred: input.pullDeferred,
61
70
  };
62
71
  const outcome = (cycleOutcome) => resultOf(cycleOutcome, null, carried);
63
72
  try {
@@ -115,6 +124,12 @@ async function runCycle(input, deps, carried, outcome) {
115
124
  // The commit's parent when this cycle committed: step 9 records the pull
116
125
  // from there, so the repair covers the commit and the pull in one move.
117
126
  let committedFrom = null;
127
+ // Which dirty paths the pull may set aside (design 14.21): after a clean
128
+ // status none, so a path dirty at step 9 is a save made during the cycle.
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;
118
133
  if ((await deps.git.status()).length > 0) {
119
134
  // A person's merge, cherry-pick or revert that stopped after step 2 is in
120
135
  // that status; the commit step would only find it under index.lock (Decision 52).
@@ -135,9 +150,18 @@ async function runCycle(input, deps, carried, outcome) {
135
150
  }
136
151
  if (committed.kind === 'waiting') {
137
152
  waiting = committed.reason;
153
+ // A rejected commit leaves every dirty path uncommittable; a rejecting
154
+ // hook must not block incoming changes (D4), so the pull holds them all.
155
+ if (committed.reason === 'COMMIT_HOOK_REJECTED') {
156
+ hold = { kind: 'all' };
157
+ }
158
+ }
159
+ if (committed.kind === 'committed' || committed.kind === 'nothing') {
160
+ hold = committed.hold;
138
161
  }
139
162
  if (committed.kind === 'committed') {
140
163
  committedFrom = committed.memory.lastMove?.from ?? null;
164
+ committedThisCycle = true;
141
165
  }
142
166
  }
143
167
  else if (carried.commitMemory !== undefined) {
@@ -156,7 +180,10 @@ async function runCycle(input, deps, carried, outcome) {
156
180
  let verifiedSha = await deps.git.resolveCommit(verifiedRef);
157
181
  if (remoteHead.kind === 'unreachable') {
158
182
  await reportUnpushedAgainstLastKnown(trackingSha ?? verifiedSha, upstream, cycleId, deps);
159
- return outcome({ kind: 'offline' });
183
+ return outcome({
184
+ kind: 'offline',
185
+ detail: offlineDetail(remoteHead.detail),
186
+ });
160
187
  }
161
188
  if (remoteHead.kind === 'absent') {
162
189
  // A deleted remote branch is never recreated by enmetu, so commits pile
@@ -175,7 +202,7 @@ async function runCycle(input, deps, carried, outcome) {
175
202
  const fetched = await deps.writer.fetch(upstream.remote, upstream.remoteRef, incomingRefFor(branchRef));
176
203
  if (fetched.kind === 'failed') {
177
204
  await reportUnpushedAgainstLastKnown(trackingSha ?? verifiedSha, upstream, cycleId, deps);
178
- return outcome({ kind: 'offline' });
205
+ return outcome({ kind: 'offline', detail: offlineDetail(fetched.detail) });
179
206
  }
180
207
  }
181
208
  // Checked even when the fetch was skipped: a person's own fetch may already
@@ -198,6 +225,11 @@ async function runCycle(input, deps, carried, outcome) {
198
225
  // --- Step 9: integrate when behind ---
199
226
  if (behind === 0) {
200
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
+ }
201
233
  }
202
234
  else {
203
235
  // git rebase refuses an index that differs from HEAD (Decision 84). A
@@ -210,6 +242,16 @@ async function runCycle(input, deps, carried, outcome) {
210
242
  reportUnpushed(ahead, upstream, cycleId, deps);
211
243
  return outcome({ kind: 'waiting', reason: waiting });
212
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
+ }
213
255
  const pulledFrom = committedFrom ?? (await deps.git.headCommit());
214
256
  const integrated = await deps.integrate({
215
257
  cycleId,
@@ -217,7 +259,16 @@ async function runCycle(input, deps, carried, outcome) {
217
259
  ontoSha: remoteSha,
218
260
  behind,
219
261
  knownConflict: input.knownConflict,
262
+ hold,
220
263
  });
264
+ if (integrated.kind === 'deferred') {
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;
268
+ reportUnpushed(ahead, upstream, cycleId, deps);
269
+ return outcome({ kind: 'waiting', reason: 'TREE_CHANGING' });
270
+ }
271
+ carried.pullDeferred = resetFlag(carried.pullDeferred);
221
272
  if (integrated.kind === 'not-started') {
222
273
  const refusals = (carried.notStartedRebases ?? 0) + 1;
223
274
  carried.notStartedRebases = refusals;
@@ -296,8 +347,13 @@ function resultOf(cycleOutcome, conflict, carried) {
296
347
  conflict,
297
348
  commitMemory: carried.commitMemory,
298
349
  notStartedRebases: carried.notStartedRebases,
350
+ pullDeferred: carried.pullDeferred,
299
351
  };
300
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
+ }
301
357
  /** A count the input carried goes back to 0; an absent one stays absent, which means 0. */
302
358
  function resetCount(count) {
303
359
  return count === undefined ? undefined : 0;
@@ -330,6 +386,15 @@ function reportUnpushed(ahead, upstream, cycleId, deps) {
330
386
  * observation of the remote; the verified ref covers a tracking ref that
331
387
  * `git fetch --prune` removed after the remote branch was deleted.
332
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
+ }
333
398
  async function reportUnpushedAgainstLastKnown(lastKnownSha, upstream, cycleId, deps) {
334
399
  if (lastKnownSha === null) {
335
400
  deps.logger.log({
@@ -3,6 +3,7 @@ import { errorMessage } from '../core/errors/errorMessage.js';
3
3
  const WAITING_MESSAGES = {
4
4
  PARTIAL_SELECTION_STAGED: 'waiting: a partial selection is staged; nothing is committed or pulled until you commit or unstage it, and after partialStagingWaitMs enmetu commits everything',
5
5
  COMMIT_HOOK_REJECTED: 'waiting: a commit hook rejected the commit; enmetu tries again when the files change',
6
+ TREE_CHANGING: 'waiting: a file was saved while this cycle ran; the pull waits for a cycle that finds nothing new to commit, so no file you are editing is set aside',
6
7
  };
7
8
  /**
8
9
  * Service: the memory that spans cycles. Numbers cycles c1, c2, ...; passes
@@ -33,10 +34,12 @@ export class SyncCycleRunner {
33
34
  this.knownConflict = result.conflict;
34
35
  if (this.carried !== null ||
35
36
  result.commitMemory !== undefined ||
36
- result.notStartedRebases !== undefined) {
37
+ result.notStartedRebases !== undefined ||
38
+ result.pullDeferred !== undefined) {
37
39
  this.carried = {
38
40
  commitMemory: result.commitMemory ?? EMPTY_COMMIT_MEMORY,
39
41
  notStartedRebases: result.notStartedRebases ?? 0,
42
+ pullDeferred: result.pullDeferred ?? false,
40
43
  };
41
44
  }
42
45
  this.reportQuietTransition(result.outcome, cycleId);
@@ -64,6 +67,7 @@ export class SyncCycleRunner {
64
67
  knownConflict: this.knownConflict,
65
68
  commitMemory: this.carried.commitMemory,
66
69
  notStartedRebases: this.carried.notStartedRebases,
70
+ pullDeferred: this.carried.pullDeferred,
67
71
  };
68
72
  }
69
73
  reportQuietTransition(outcome, cycleId) {
@@ -132,7 +136,7 @@ function quietEntry(outcome, cycleId) {
132
136
  return {
133
137
  level: 'WARN',
134
138
  code: 'OFFLINE',
135
- message: 'remote unreachable; retrying on every poll',
139
+ message: `remote unreachable (${outcome.detail}); retrying on every poll`,
136
140
  cycleId,
137
141
  };
138
142
  case 'waiting':
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fluado/enmetu",
3
- "version": "0.3.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",