@fluado/enmetu 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -14,7 +14,7 @@ Most auto-commit tools commit and push. enmetu also handles what goes wrong:
14
14
 
15
15
  - **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
16
  - **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.
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, 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
18
  - **Halts.** A rewritten upstream, or a state it cannot verify, halts it. The halt survives restarts until you run `enmetu resume`.
19
19
  - **Status.** `enmetu status` says whether your work is committed and pushed, and during a conflict prints the commands to end it.
20
20
  - **Dependencies.** None besides git. Runs under Node or Bun, as a command or as a library.
@@ -52,7 +52,7 @@ Keep `--bun`: without it, `bunx` runs enmetu with Node when Node is installed.
52
52
 
53
53
  ### Pinning
54
54
 
55
- Add the version for repeatable runs: `@fluado/enmetu@0.3.0`.
55
+ Add the version for repeatable runs: `@fluado/enmetu@0.4.0`.
56
56
 
57
57
  ## Commands
58
58
 
@@ -190,6 +190,7 @@ stderr, one line per event:
190
190
  | `EXCLUDED` | file not committed: conflict markers or invalid JSON |
191
191
  | `CONFLICT`, `UNPUSHED` | local commits not pushed |
192
192
  | `WAITING`, `PAUSED`, `OFFLINE` | sync on hold |
193
+ | `HELD`, `RESTORED` | a file enmetu could not commit was set aside for a pull and put back; see Known limitations |
193
194
  | `HALTED` | see `enmetu status` |
194
195
  | `MESSAGE_COMMAND_FAILED`, `MESSAGE_WRITER_FAILED` | message from the fallback named in the line |
195
196
  | `COMMIT_ORPHANED`, `STAGED_RESET`, `STAGED_REVERT`, `QUARANTINE_KEPT` | see Known limitations |
@@ -231,12 +232,15 @@ if command -v enmetu >/dev/null 2>&1; then enmetu hook pre-commit || exit 1; els
231
232
  - `commit-msg` and `prepare-commit-msg` hooks don't run on enmetu's commits; `commitMessageCommand` writes the message instead.
232
233
  - Under Bun without file-watching permission, enmetu commits on its poll interval.
233
234
  - 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.
235
+ - A file written faster than a cycle runs keeps the pull waiting (`WAITING` with `TREE_CHANGING`) until it stops.
236
+ - 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.
237
+ - A save landing in the milliseconds before git rewrites a file, inside a pull or its abort, is lost without a record.
235
238
 
236
239
  Overlapping git commands:
237
240
 
238
241
  | Symptom | Cause | Fix |
239
242
  |---|---|---|
243
+ | 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
244
  | `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
245
  | `STAGED_RESET` | staged during enmetu's rebase | command in the log line |
242
246
  | `QUARANTINE_KEPT` | your edit collided with a version enmetu held aside | command in the log line |
@@ -247,7 +251,7 @@ Overlapping git commands:
247
251
 
248
252
  ## Contributing
249
253
 
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.
254
+ 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
255
 
252
256
  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
257
 
@@ -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
  }
@@ -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';
@@ -11,11 +11,30 @@ export type PauseReason = 'FOREIGN_OPERATION' | 'DETACHED_HEAD' | 'NO_UPSTREAM'
11
11
  */
12
12
  | 'REBASE_REFUSED';
13
13
  /**
14
- * Why a cycle committed nothing although the tree has changes (design 12.7):
15
- * a person has a selection staged and partialStagingWaitMs has not passed
16
- * (design 14.17), or a repository commit hook rejected enmetu's commit.
14
+ * Why a cycle did less than everything although the tree has changes (design
15
+ * 12.7): a person has a selection staged and partialStagingWaitMs has not
16
+ * passed (design 14.17), a repository commit hook rejected enmetu's commit, or
17
+ * a file was saved while the cycle ran and the pull waits for a cycle that
18
+ * finds nothing new to commit (design 14.21, Decision 147).
17
19
  */
18
- export type WaitReason = 'PARTIAL_SELECTION_STAGED' | 'COMMIT_HOOK_REJECTED';
20
+ export type WaitReason = 'PARTIAL_SELECTION_STAGED' | 'COMMIT_HOOK_REJECTED' | 'TREE_CHANGING';
21
+ /**
22
+ * Which dirty tracked paths integrate may set aside for a pull (design 14.21,
23
+ * Decision 147): the ones the commit step could not commit this cycle. Any
24
+ * other dirty path is a save that landed during the cycle, and the pull waits.
25
+ */
26
+ export type HoldPolicy =
27
+ /** Exactly these paths (the content guard's EXCLUDED paths); empty when the cycle committed everything or found nothing. */
28
+ {
29
+ readonly kind: 'only';
30
+ readonly paths: readonly string[];
31
+ }
32
+ /** Every dirty tracked path: a commit hook rejected the commit, or the hooks' residue is all that changed. */
33
+ | {
34
+ readonly kind: 'all';
35
+ };
36
+ /** The policy after a clean status: any dirty path at the pull is a save made during the cycle. */
37
+ export declare const HOLD_NOTHING: HoldPolicy;
19
38
  export type CycleOutcome = {
20
39
  readonly kind: 'synced';
21
40
  } | {
@@ -1,3 +1,5 @@
1
+ /** The policy after a clean status: any dirty path at the pull is a save made during the cycle. */
2
+ export const HOLD_NOTHING = { kind: 'only', paths: [] };
1
3
  export const EMPTY_COMMIT_MEMORY = {
2
4
  partialStagingSince: null,
3
5
  rejection: null,
@@ -2,6 +2,8 @@ import type { ILogger } from '../../contracts/ILogger.js';
2
2
  import type { KeptBlob } from '../../contracts/IPathQuarantine.js';
3
3
  /** What a restore reports that a person should see; the fields of RestoreResult's restored. */
4
4
  export interface RestoreWarnings {
5
+ /** Paths put back; 0 logs no RESTORED line (design 14.21). */
6
+ readonly count: number;
5
7
  readonly kept: readonly KeptBlob[];
6
8
  readonly changedUnderHold: readonly string[];
7
9
  readonly overwritten: readonly KeptBlob[];
@@ -9,8 +11,9 @@ export interface RestoreWarnings {
9
11
  /** The QUARANTINE_UPSTREAM_CHANGED message (Decision 99). */
10
12
  export declare function changedUnderHoldMessage(path: string): string;
11
13
  /**
12
- * One WARN per kept blob and per path restored over a change, the same at
13
- * every restore: recovery, a pending memento, and integrate (written three
14
- * times before it was extracted).
14
+ * One INFO RESTORED with the count of paths put back (Decision 148), then one
15
+ * WARN per kept blob and per path restored over a change, the same at every
16
+ * restore: recovery, a pending memento, and integrate (written three times
17
+ * before it was extracted).
15
18
  */
16
19
  export declare function logRestoreWarnings(warnings: RestoreWarnings, cycleId: string, logger: ILogger): void;
@@ -4,11 +4,20 @@ export function changedUnderHoldMessage(path) {
4
4
  return `${path} changed in HEAD while it was held (a pulled change) and the held edits do not merge cleanly onto it; the held version is back in the working tree, so committing it undoes that change (compare with git diff HEAD -- ${path})`;
5
5
  }
6
6
  /**
7
- * One WARN per kept blob and per path restored over a change, the same at
8
- * every restore: recovery, a pending memento, and integrate (written three
9
- * times before it was extracted).
7
+ * One INFO RESTORED with the count of paths put back (Decision 148), then one
8
+ * WARN per kept blob and per path restored over a change, the same at every
9
+ * restore: recovery, a pending memento, and integrate (written three times
10
+ * before it was extracted).
10
11
  */
11
12
  export function logRestoreWarnings(warnings, cycleId, logger) {
13
+ if (warnings.count > 0) {
14
+ logger.log({
15
+ level: 'INFO',
16
+ code: 'RESTORED',
17
+ message: `${warnings.count === 1 ? '1 held path' : `${warnings.count} held paths`} put back`,
18
+ cycleId,
19
+ });
20
+ }
12
21
  for (const kept of warnings.kept) {
13
22
  logger.log({
14
23
  level: 'WARN',
@@ -169,6 +169,7 @@ const STATE_REASONS = [
169
169
  'REBASE_REFUSED',
170
170
  'PARTIAL_SELECTION_STAGED',
171
171
  'COMMIT_HOOK_REJECTED',
172
+ 'TREE_CHANGING',
172
173
  ...HALT_CODES,
173
174
  ];
174
175
  const OUTCOME_KINDS = [
@@ -69,6 +69,9 @@ function runningHeadline(record, uncommitted, unpushed) {
69
69
  if (record.reason === 'COMMIT_HOOK_REJECTED') {
70
70
  return `waiting: a commit hook rejected the last commit since ${since}, ${work}`;
71
71
  }
72
+ if (record.reason === 'TREE_CHANGING') {
73
+ return `waiting: a save landed during the last cycle since ${since}; the pull waits for a quiet cycle, ${work}`;
74
+ }
72
75
  return `waiting:${reasonText(record)} since ${since}, ${work}`;
73
76
  case 'paused':
74
77
  return `paused:${reasonText(record)} since ${since}, ${work}`;
@@ -6,7 +6,7 @@ import type { ILogger } from '../contracts/ILogger.js';
6
6
  import type { IOperationJournal } from '../contracts/IOperationJournal.js';
7
7
  import type { IPathQuarantine } from '../contracts/IPathQuarantine.js';
8
8
  import type { IOrphanedCommitRecorder } from '../contracts/IStateRecorder.js';
9
- import type { ConflictKey, PauseReason } from '../contracts/syncTypes.js';
9
+ import type { ConflictKey, HoldPolicy, PauseReason } from '../contracts/syncTypes.js';
10
10
  export interface IntegrateUpstreamInput {
11
11
  readonly cycleId: string;
12
12
  /** The branch the cycle resolved its upstream for. */
@@ -16,6 +16,8 @@ export interface IntegrateUpstreamInput {
16
16
  /** Commits the upstream has that HEAD lacks, for the PULLED line. */
17
17
  readonly behind: number;
18
18
  readonly knownConflict: ConflictKey | null;
19
+ /** Which dirty tracked paths step 2 may hold (design 14.21, Decision 147). */
20
+ readonly hold: HoldPolicy;
19
21
  }
20
22
  export interface IntegrateUpstreamDeps {
21
23
  readonly git: IGitReader;
@@ -51,6 +53,14 @@ export type IntegrateUpstreamResult = {
51
53
  } | {
52
54
  readonly kind: 'halted';
53
55
  readonly code: HaltCode;
56
+ }
57
+ /**
58
+ * Dirty tracked paths the hold policy does not name were found: saves made
59
+ * during the cycle. Nothing was held or rebased (design 14.21, Decision 147).
60
+ */
61
+ | {
62
+ readonly kind: 'deferred';
63
+ readonly paths: readonly string[];
54
64
  };
55
65
  /**
56
66
  * The integrate step with its deps bound, the shape the sync cycle consumes. The
@@ -63,7 +73,11 @@ export type IntegrateUpstream = (input: IntegrateUpstreamInput) => Promise<Integ
63
73
  * paths held aside (design 1.11).
64
74
  *
65
75
  * 1. head = git.headCommit(); an identical knownConflict logs CONFLICT and returns it (Decision 11).
66
- * 2. Dirty tracked paths: quarantine.hold; pending returns busy, unsupported pauses QUARANTINE_UNSUPPORTED.
76
+ * 2. Dirty tracked paths: any the hold policy does not name is a save made
77
+ * during the cycle, and the step returns deferred with those paths, nothing
78
+ * held or rebased (design 14.21, Decision 147). Otherwise HELD names the
79
+ * paths (Decision 148) and quarantine.hold runs; pending returns busy,
80
+ * unsupported pauses QUARANTINE_UNSUPPORTED.
67
81
  * 3. From here on in try/finally:
68
82
  * a. git.repositoryState() again: operation pauses FOREIGN_OPERATION, indexLocked returns busy,
69
83
  * another branch pauses BRANCH_CHANGED.
@@ -9,7 +9,11 @@ const SHORT_SHA_LENGTH = 7;
9
9
  * paths held aside (design 1.11).
10
10
  *
11
11
  * 1. head = git.headCommit(); an identical knownConflict logs CONFLICT and returns it (Decision 11).
12
- * 2. Dirty tracked paths: quarantine.hold; pending returns busy, unsupported pauses QUARANTINE_UNSUPPORTED.
12
+ * 2. Dirty tracked paths: any the hold policy does not name is a save made
13
+ * during the cycle, and the step returns deferred with those paths, nothing
14
+ * held or rebased (design 14.21, Decision 147). Otherwise HELD names the
15
+ * paths (Decision 148) and quarantine.hold runs; pending returns busy,
16
+ * unsupported pauses QUARANTINE_UNSUPPORTED.
13
17
  * 3. From here on in try/finally:
14
18
  * a. git.repositoryState() again: operation pauses FOREIGN_OPERATION, indexLocked returns busy,
15
19
  * another branch pauses BRANCH_CHANGED.
@@ -65,8 +69,24 @@ export async function integrateUpstreamController(input, deps) {
65
69
  entry.worktree === 'D' ||
66
70
  entry.worktree === 'T'))
67
71
  .map((entry) => entry.path);
72
+ // A dirty path the commit step could have committed is a save that landed
73
+ // since its add -A: holding it would rewrite a file the person is editing
74
+ // (design 14.21). The pull waits for a cycle that commits it first.
75
+ const policy = input.hold;
76
+ const fresh = policy.kind === 'all'
77
+ ? []
78
+ : dirty.filter((path) => !policy.paths.includes(path));
79
+ if (fresh.length > 0) {
80
+ return { kind: 'deferred', paths: fresh };
81
+ }
68
82
  let held = false;
69
83
  if (dirty.length > 0) {
84
+ deps.logger.log({
85
+ level: 'WARN',
86
+ code: 'HELD',
87
+ message: `set aside for the pull, showing the committed version until RESTORED: ${dirty.join(', ')}`,
88
+ cycleId,
89
+ });
70
90
  const hold = await deps.quarantine.hold(dirty);
71
91
  if (hold.kind === 'pending') {
72
92
  return { kind: 'busy' };
@@ -42,7 +42,9 @@ export interface SyncCycleDeps {
42
42
  * memory (design 12.7); busy and paused return, committed and nothing go
43
43
  * on, waiting goes on and is the outcome unless a later step returns
44
44
  * something else. The check right before the commit is the commit step's,
45
- * under index.lock.
45
+ * under index.lock. The hold policy for step 9 (design 14.21): the commit
46
+ * step's after committed or nothing, all after a hook rejection, nothing
47
+ * after a clean status.
46
48
  * 6. Remote check: ls-remote, tracking ref, verified ref (seeded from tracking when missing).
47
49
  * 7. Verify and fetch: fetch into the incoming ref when the remote moved; the remote sha
48
50
  * must descend from the verified sha, else halt UPSTREAM_REWRITTEN; then move the
@@ -53,6 +55,9 @@ export interface SyncCycleDeps {
53
55
  * git.hasStagedChanges() says the real index differs from HEAD (git would
54
56
  * refuse the rebase), integrate is skipped, UNPUSHED reported and waiting
55
57
  * returned (Decision 84).
58
+ * deferred (a save made during the cycle is dirty): UNPUSHED reported,
59
+ * waiting TREE_CHANGING returned, the refusal count left as it is
60
+ * (Decision 147).
56
61
  * not-started: the count of consecutive refusals (input.notStartedRebases)
57
62
  * goes up by one; the first logs REBASE_NOT_STARTED with git's first stderr
58
63
  * line; UNPUSHED is reported; the outcome is busy, or paused REBASE_REFUSED
@@ -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,9 @@ 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).
42
47
  * not-started: the count of consecutive refusals (input.notStartedRebases)
43
48
  * goes up by one; the first logs REBASE_NOT_STARTED with git's first stderr
44
49
  * line; UNPUSHED is reported; the outcome is busy, or paused REBASE_REFUSED
@@ -115,6 +120,9 @@ async function runCycle(input, deps, carried, outcome) {
115
120
  // The commit's parent when this cycle committed: step 9 records the pull
116
121
  // from there, so the repair covers the commit and the pull in one move.
117
122
  let committedFrom = null;
123
+ // Which dirty paths the pull may set aside (design 14.21): after a clean
124
+ // status none, so a path dirty at step 9 is a save made during the cycle.
125
+ let hold = HOLD_NOTHING;
118
126
  if ((await deps.git.status()).length > 0) {
119
127
  // A person's merge, cherry-pick or revert that stopped after step 2 is in
120
128
  // that status; the commit step would only find it under index.lock (Decision 52).
@@ -135,6 +143,14 @@ async function runCycle(input, deps, carried, outcome) {
135
143
  }
136
144
  if (committed.kind === 'waiting') {
137
145
  waiting = committed.reason;
146
+ // A rejected commit leaves every dirty path uncommittable; a rejecting
147
+ // hook must not block incoming changes (D4), so the pull holds them all.
148
+ if (committed.reason === 'COMMIT_HOOK_REJECTED') {
149
+ hold = { kind: 'all' };
150
+ }
151
+ }
152
+ if (committed.kind === 'committed' || committed.kind === 'nothing') {
153
+ hold = committed.hold;
138
154
  }
139
155
  if (committed.kind === 'committed') {
140
156
  committedFrom = committed.memory.lastMove?.from ?? null;
@@ -217,7 +233,13 @@ async function runCycle(input, deps, carried, outcome) {
217
233
  ontoSha: remoteSha,
218
234
  behind,
219
235
  knownConflict: input.knownConflict,
236
+ hold,
220
237
  });
238
+ if (integrated.kind === 'deferred') {
239
+ // The next cycle commits the save first; the runner logs WAITING once.
240
+ reportUnpushed(ahead, upstream, cycleId, deps);
241
+ return outcome({ kind: 'waiting', reason: 'TREE_CHANGING' });
242
+ }
221
243
  if (integrated.kind === 'not-started') {
222
244
  const refusals = (carried.notStartedRebases ?? 0) + 1;
223
245
  carried.notStartedRebases = refusals;
@@ -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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fluado/enmetu",
3
- "version": "0.3.0",
3
+ "version": "0.4.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)",