@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 +8 -4
- package/dist/commit/commitLocalChangesController.d.ts +15 -5
- package/dist/commit/commitLocalChangesController.js +21 -14
- package/dist/commit/stageGuardedChanges.d.ts +3 -1
- package/dist/commit/stageGuardedChanges.js +5 -4
- package/dist/contracts/logCodes.d.ts +1 -1
- package/dist/contracts/syncTypes.d.ts +23 -4
- package/dist/contracts/syncTypes.js +2 -0
- package/dist/core/quarantine/restoreWarnings.d.ts +6 -3
- package/dist/core/quarantine/restoreWarnings.js +12 -3
- package/dist/core/state/stateGuards.js +1 -0
- package/dist/core/status/statusHeadline.js +3 -0
- package/dist/sync/integrateUpstreamController.d.ts +16 -2
- package/dist/sync/integrateUpstreamController.js +21 -1
- package/dist/sync/syncCycleController.d.ts +6 -1
- package/dist/sync/syncCycleController.js +24 -2
- package/dist/sync/syncCycleRunner.js +1 -0
- package/package.json +1 -1
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,
|
|
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.
|
|
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
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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
|
-
|
|
115
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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(
|
|
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
|
|
15
|
-
* a person has a selection staged and partialStagingWaitMs has not
|
|
16
|
-
* (design 14.17),
|
|
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
|
} | {
|
|
@@ -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
|
|
13
|
-
*
|
|
14
|
-
*
|
|
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
|
|
8
|
-
*
|
|
9
|
-
*
|
|
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',
|
|
@@ -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:
|
|
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:
|
|
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
|