@omgbase/git-adapter 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +38 -2
- package/dist/src/config.d.ts +62 -0
- package/dist/src/config.js +91 -0
- package/dist/src/cursor.d.ts +24 -0
- package/dist/src/cursor.js +47 -0
- package/dist/src/exclude.d.ts +5 -0
- package/dist/src/exclude.js +41 -0
- package/dist/src/git.d.ts +157 -0
- package/dist/src/git.js +356 -0
- package/dist/src/ids.d.ts +48 -0
- package/dist/src/ids.js +84 -0
- package/dist/src/index.d.ts +11 -0
- package/dist/src/index.js +17 -0
- package/dist/src/provider.d.ts +63 -0
- package/dist/src/provider.js +113 -0
- package/dist/src/reader.d.ts +44 -0
- package/dist/src/reader.js +190 -0
- package/dist/src/remote.d.ts +113 -0
- package/dist/src/remote.js +631 -0
- package/dist/src/scheduler.d.ts +20 -0
- package/dist/src/scheduler.js +37 -0
- package/dist/src/trailers.d.ts +41 -0
- package/dist/src/trailers.js +124 -0
- package/dist/src/worker.d.ts +50 -0
- package/dist/src/worker.js +202 -0
- package/package.json +51 -3
|
@@ -0,0 +1,631 @@
|
|
|
1
|
+
import { EngineError, clearAlert, feedStatus, listActiveAlerts, raiseAlert, reconstructContent, repoEpoch, updateHistoryProviderConfig } from "@omgbase/core";
|
|
2
|
+
import { EMPTY_TREE } from "./git.js";
|
|
3
|
+
import { parseMessage, renderMessage } from "./trailers.js";
|
|
4
|
+
import { readGitCursor, writeGitCursor, writeGitPushed } from "./cursor.js";
|
|
5
|
+
import { remoteConfigFor } from "./config.js";
|
|
6
|
+
import { resumePosition, runCheckpoint } from "./worker.js";
|
|
7
|
+
// The remote half of the git history provider (spec/store §5.12 "The remote
|
|
8
|
+
// binding" … "Divergence", 17.6 — modular-core Phase 6b; the design note's §8
|
|
9
|
+
// and "Remote divergence"). Sync is coordination between the engine's branch
|
|
10
|
+
// and the remote's: OUTBOUND is `git push` — never `--force`; a rejected push
|
|
11
|
+
// is reported, not forced — INBOUND is `git fetch` into the bare repository and
|
|
12
|
+
// a THREE-WAY MERGE AT THE TREE LEVEL with the common ancestor git supplies
|
|
13
|
+
// (the piece optional native history took away), whose clean result flows into
|
|
14
|
+
// the vault THROUGH THE SOURCE BOUNDARY (`HistoryVaultSink`: external edits the
|
|
15
|
+
// ordinary reconcile matches identities for, the feed records, the worker then
|
|
16
|
+
// finds already in the tree) and whose conflicts are surfaced with BOTH
|
|
17
|
+
// versions kept (the remote's as a sidecar beside ours) until the operator
|
|
18
|
+
// settles each. A REWRITTEN remote (rebased, force-pushed — its tip no longer
|
|
19
|
+
// descends from the tip we last knew) is detected, stopped and alerted; push
|
|
20
|
+
// and fetch pause, checkpointing continues, and one of four explicit
|
|
21
|
+
// remediations is the operator's: `--rebase`, `--merge`, `--adopt`, `--fork`.
|
|
22
|
+
// NEVER: a force-push, unpushed local commits dropped without a backup ref, a
|
|
23
|
+
// silent last-write-wins.
|
|
24
|
+
//
|
|
25
|
+
// Refs this module keeps in the bare repository, all under names git itself
|
|
26
|
+
// never writes: `refs/remotes/<name>/<branch>` the remote-tracking ref (the
|
|
27
|
+
// last tip seen on the remote — moved by fetch and by a successful push);
|
|
28
|
+
// `refs/omgbase/remotes/<name>/ids` the remote's ids ref as fetched;
|
|
29
|
+
// `refs/omgbase/diverged/<name>` the OLD remote tip while a divergence stands
|
|
30
|
+
// (the base `--rebase` and `--merge` use; the objects stay reachable);
|
|
31
|
+
// `refs/omgbase/merge/<name>` the remote tip a merge with conflicts is waiting
|
|
32
|
+
// on; `refs/omgbase/backup/<ts>` our previous tip before a remediation moved
|
|
33
|
+
// the branch.
|
|
34
|
+
export const GIT_REMOTE_DIVERGED_ALERT = "git_remote_diverged";
|
|
35
|
+
export const GIT_MERGE_CONFLICT_ALERT = "git_merge_conflict";
|
|
36
|
+
export const GIT_REMOTE_FORKED_ALERT = "git_remote_forked";
|
|
37
|
+
// ---- refs and helpers ----
|
|
38
|
+
const trackingRef = (r) => `refs/remotes/${r.name}/${r.branch}`;
|
|
39
|
+
const idsTrackingRef = (r) => `refs/omgbase/remotes/${r.name}/ids`;
|
|
40
|
+
const divergedRef = (r) => `refs/omgbase/diverged/${r.name}`;
|
|
41
|
+
const mergeRef = (r) => `refs/omgbase/merge/${r.name}`;
|
|
42
|
+
const short = (sha) => sha.slice(0, 7);
|
|
43
|
+
/** `<ts>` as a ref/branch segment: `20260927T000000Z`. */
|
|
44
|
+
export function tsSegment(ts) {
|
|
45
|
+
return ts.replace(/[-:]/g, "").replace(/\.\d+Z$/, "Z");
|
|
46
|
+
}
|
|
47
|
+
/** The sidecar beside `path` holding the OTHER side's version: `<stem>.remote-<short>.md` / `<stem>.local-<short>.md` (a path without `.md` keeps its name and gains the suffix). */
|
|
48
|
+
export function sidecarPath(path, side, sha) {
|
|
49
|
+
const m = /^(.*)\.md$/.exec(path);
|
|
50
|
+
return m ? `${m[1]}.${side}-${short(sha)}.md` : `${path}.${side}-${short(sha)}`;
|
|
51
|
+
}
|
|
52
|
+
function requireRemote(ctx) {
|
|
53
|
+
const r = ctx.config.remote;
|
|
54
|
+
if (!r)
|
|
55
|
+
throw new Error("no remote is bound to this repository's git history — run omg history remote set --url <url> first");
|
|
56
|
+
return r;
|
|
57
|
+
}
|
|
58
|
+
function activeAlert(ctx, code) {
|
|
59
|
+
const a = listActiveAlerts(ctx.store.db, ctx.repoId).find((x) => x.code === code);
|
|
60
|
+
return a ? { detail: a.detail ?? {} } : null;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Are push and fetch PAUSED? While the remote stands rewritten
|
|
64
|
+
* (`git_remote_diverged` carrying `old_remote_tip`) or forked, nothing moves
|
|
65
|
+
* either way until a remediation; a plain rejected push (no `old_remote_tip`)
|
|
66
|
+
* pauses nothing — `history fetch` merges the remote's commits and clears it.
|
|
67
|
+
*/
|
|
68
|
+
export function pausedBy(ctx) {
|
|
69
|
+
const diverged = activeAlert(ctx, GIT_REMOTE_DIVERGED_ALERT);
|
|
70
|
+
if (diverged && "old_remote_tip" in diverged.detail)
|
|
71
|
+
return { code: GIT_REMOTE_DIVERGED_ALERT, detail: diverged.detail };
|
|
72
|
+
const forked = activeAlert(ctx, GIT_REMOTE_FORKED_ALERT);
|
|
73
|
+
if (forked)
|
|
74
|
+
return { code: GIT_REMOTE_FORKED_ALERT, detail: forked.detail };
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
function pausedError(paused) {
|
|
78
|
+
const d = paused.detail;
|
|
79
|
+
const message = paused.code === GIT_REMOTE_FORKED_ALERT
|
|
80
|
+
? forkedMessage(d.remote, d.branch, d.fork ?? "")
|
|
81
|
+
: rewrittenMessage(d.remote, d.branch, d.remote_tip ?? null, d.old_remote_tip ?? null);
|
|
82
|
+
// The data in the pinned key order (the alert row stores its detail canonically sorted).
|
|
83
|
+
const data = { remote: d.remote, branch: d.branch, local_tip: d.local_tip ?? null, remote_tip: d.remote_tip ?? null, old_remote_tip: d.old_remote_tip ?? null, ...(d.fork !== undefined ? { fork: d.fork } : {}) };
|
|
84
|
+
return new EngineError("git_remote_diverged", message, { data, retriable: true });
|
|
85
|
+
}
|
|
86
|
+
function pendingMergeError(ctx, r) {
|
|
87
|
+
const conflict = activeAlert(ctx, GIT_MERGE_CONFLICT_ALERT);
|
|
88
|
+
if (!conflict)
|
|
89
|
+
return null;
|
|
90
|
+
const paths = conflict.detail.paths ?? [];
|
|
91
|
+
return new EngineError("git_merge_conflict", conflictMessage(r.name, r.branch, conflict.detail.remote_tip ?? null, paths), { data: conflict.detail, retriable: true });
|
|
92
|
+
}
|
|
93
|
+
// ---- the pinned messages (byte for byte in both engines) ----
|
|
94
|
+
export function rejectedMessage(remote, branch, remoteTip, localTip) {
|
|
95
|
+
return `push to ${remote}/${branch} was rejected: the remote is at ${remoteTip ?? "an unknown tip"}, which does not descend from ${localTip ?? "our tip"} — run omg history fetch to merge the remote's commits, then push again`;
|
|
96
|
+
}
|
|
97
|
+
export function rewrittenMessage(remote, branch, remoteTip, oldRemoteTip) {
|
|
98
|
+
return `the remote ${remote}/${branch} was rewritten: its tip ${remoteTip ?? "(none)"} does not descend from the last known ${oldRemoteTip ?? "(none — unrelated histories)"} — push and fetch are paused while checkpoints continue locally; choose omg history remote reconcile --rebase | --merge | --adopt | --fork`;
|
|
99
|
+
}
|
|
100
|
+
export function conflictMessage(remote, branch, remoteTip, paths) {
|
|
101
|
+
return `the merge of ${remote}/${branch} at ${remoteTip ?? "(none)"} left ${paths.length} conflict(s): ${paths.join(", ")} — both versions are in the vault; settle each with omg history remote resolve <path> --ours|--theirs`;
|
|
102
|
+
}
|
|
103
|
+
export function forkedMessage(remote, branch, fork) {
|
|
104
|
+
return `the engine's branch was pushed to ${remote}/${fork} — merge it into ${branch} with git, then run omg history remote reconcile --adopt (push and fetch stay paused until then)`;
|
|
105
|
+
}
|
|
106
|
+
// ---- the binding ----
|
|
107
|
+
/** `history remote set`: the remote in the bare repository and the row's `remote` member; returns the config as written. */
|
|
108
|
+
export function remoteSet(ctx, opts, now) {
|
|
109
|
+
const remote = remoteConfigFor(opts, ctx.config.branch);
|
|
110
|
+
ctx.git.init(ctx.config.branch);
|
|
111
|
+
const previous = ctx.config.remote;
|
|
112
|
+
if (previous && previous.name !== remote.name)
|
|
113
|
+
forgetRemote(ctx, previous, now);
|
|
114
|
+
ctx.git.setRemote(remote.name, remote.url);
|
|
115
|
+
const config = { ...ctx.config, remote };
|
|
116
|
+
updateHistoryProviderConfig(ctx.store.db, ctx.repoId, config, now);
|
|
117
|
+
return config;
|
|
118
|
+
}
|
|
119
|
+
/** `history remote unset`: the remote, its tracking and bookkeeping refs, the alerts about it and the row's binding leave; the pushed record is cleared. */
|
|
120
|
+
export function remoteUnset(ctx, now) {
|
|
121
|
+
const previous = ctx.config.remote;
|
|
122
|
+
if (previous)
|
|
123
|
+
forgetRemote(ctx, previous, now);
|
|
124
|
+
const config = { ...ctx.config, remote: null };
|
|
125
|
+
updateHistoryProviderConfig(ctx.store.db, ctx.repoId, config, now);
|
|
126
|
+
writeGitPushed(ctx.store.db, ctx.repoId, repoEpoch(ctx.store.db, ctx.repoId), null);
|
|
127
|
+
return config;
|
|
128
|
+
}
|
|
129
|
+
function forgetRemote(ctx, r, now) {
|
|
130
|
+
if (ctx.git.exists()) {
|
|
131
|
+
ctx.git.removeRemote(r.name);
|
|
132
|
+
for (const ref of [trackingRef(r), idsTrackingRef(r), divergedRef(r), mergeRef(r)])
|
|
133
|
+
ctx.git.deleteRef(ref);
|
|
134
|
+
}
|
|
135
|
+
for (const code of [GIT_REMOTE_DIVERGED_ALERT, GIT_MERGE_CONFLICT_ALERT, GIT_REMOTE_FORKED_ALERT])
|
|
136
|
+
clearAlert(ctx.store.db, ctx.repoId, code, now);
|
|
137
|
+
}
|
|
138
|
+
/** The seq a tip's `omgbase-seq` trailer covers when it is of `epoch`, else null. */
|
|
139
|
+
function seqOf(git, tip, epoch) {
|
|
140
|
+
if (tip === null)
|
|
141
|
+
return null;
|
|
142
|
+
const t = parseMessage(git.message(tip));
|
|
143
|
+
return t && t.epoch === epoch ? t.to : null;
|
|
144
|
+
}
|
|
145
|
+
/** `history remote status` (local refs only — no network): where the two sides stand. */
|
|
146
|
+
export function remoteStatus(ctx) {
|
|
147
|
+
const r = requireRemote(ctx);
|
|
148
|
+
const git = ctx.git;
|
|
149
|
+
const local = git.exists() ? git.tip(ctx.config.branch) : null;
|
|
150
|
+
const remote = git.exists() ? git.ref(trackingRef(r)) : null;
|
|
151
|
+
const cursor = readGitCursor(ctx.store.db, ctx.repoId);
|
|
152
|
+
let state;
|
|
153
|
+
if (remote === null || local === null)
|
|
154
|
+
state = "unknown";
|
|
155
|
+
else if (activeAlert(ctx, GIT_REMOTE_DIVERGED_ALERT) !== null || activeAlert(ctx, GIT_REMOTE_FORKED_ALERT) !== null)
|
|
156
|
+
state = "diverged";
|
|
157
|
+
else if (local === remote)
|
|
158
|
+
state = "in-sync";
|
|
159
|
+
else if (git.isAncestor(remote, local))
|
|
160
|
+
state = "ahead";
|
|
161
|
+
else if (git.isAncestor(local, remote))
|
|
162
|
+
state = "behind";
|
|
163
|
+
else
|
|
164
|
+
state = "diverged";
|
|
165
|
+
return { remote: r.name, branch: r.branch, local_tip: local, remote_tip: remote, pushed_through: cursor?.pushed?.seq ?? null, state };
|
|
166
|
+
}
|
|
167
|
+
/** `version.capabilities.history.remote`. */
|
|
168
|
+
export function remoteSummary(ctx) {
|
|
169
|
+
if (!ctx.config.remote)
|
|
170
|
+
return null;
|
|
171
|
+
const s = remoteStatus(ctx);
|
|
172
|
+
return { name: s.remote, branch: s.branch, state: s.state, pushed_through: s.pushed_through };
|
|
173
|
+
}
|
|
174
|
+
// ---- push ----
|
|
175
|
+
/**
|
|
176
|
+
* `history push`: the content branch (`refs/heads/<b>:refs/heads/<rb>`) and,
|
|
177
|
+
* when it exists, the ids ref — never `--force`. A rejected push raises
|
|
178
|
+
* `git_remote_diverged` (warning; `{ remote, branch, local_tip, remote_tip }`)
|
|
179
|
+
* and throws it; the ids ref's rejection alone is reported (`ids:
|
|
180
|
+
* "rejected"`) — two engines writing one remote's identities is 6c's.
|
|
181
|
+
*/
|
|
182
|
+
export function pushBranch(ctx, now) {
|
|
183
|
+
const r = requireRemote(ctx);
|
|
184
|
+
const paused = pausedBy(ctx);
|
|
185
|
+
if (paused)
|
|
186
|
+
throw pausedError(paused);
|
|
187
|
+
// A merge with conflicts is waiting: the branch does not yet hold what the remote has, so a push could only be rejected — refuse with the conflict instead.
|
|
188
|
+
const pending = pendingMergeError(ctx, r);
|
|
189
|
+
if (pending)
|
|
190
|
+
throw pending;
|
|
191
|
+
const git = ctx.git;
|
|
192
|
+
const epoch = repoEpoch(ctx.store.db, ctx.repoId);
|
|
193
|
+
const local = git.exists() ? git.tip(ctx.config.branch) : null;
|
|
194
|
+
if (local === null)
|
|
195
|
+
return { pushed: false, tip: null, pushed_through: null, ids: "none" };
|
|
196
|
+
const before = git.ref(trackingRef(r));
|
|
197
|
+
const result = git.push(r.name, [`refs/heads/${ctx.config.branch}:refs/heads/${r.branch}`]);
|
|
198
|
+
if (result.status === "rejected") {
|
|
199
|
+
let remoteTip = null;
|
|
200
|
+
try {
|
|
201
|
+
remoteTip = git.lsRemote(r.name, `refs/heads/${r.branch}`);
|
|
202
|
+
}
|
|
203
|
+
catch { /* unreachable now: the rejection stands on its own */ }
|
|
204
|
+
const detail = { remote: r.name, branch: r.branch, local_tip: local, remote_tip: remoteTip };
|
|
205
|
+
const message = rejectedMessage(r.name, r.branch, remoteTip, local);
|
|
206
|
+
raiseAlert(ctx.store.db, ctx.repoId, { code: GIT_REMOTE_DIVERGED_ALERT, severity: "warning", message, detail: { ...detail } }, now);
|
|
207
|
+
throw new EngineError("git_remote_diverged", message, { data: detail, retriable: true });
|
|
208
|
+
}
|
|
209
|
+
if (result.status === "failed")
|
|
210
|
+
throw new Error(`push to ${r.name} failed: ${result.stderr}`);
|
|
211
|
+
git.setRef(trackingRef(r), local);
|
|
212
|
+
// A rejected push's alert ends with the push that lands.
|
|
213
|
+
clearAlert(ctx.store.db, ctx.repoId, GIT_REMOTE_DIVERGED_ALERT, now);
|
|
214
|
+
const seq = seqOf(git, local, epoch) ?? readGitCursor(ctx.store.db, ctx.repoId)?.seq ?? null;
|
|
215
|
+
writeGitPushed(ctx.store.db, ctx.repoId, epoch, { tip: local, seq });
|
|
216
|
+
let ids = "none";
|
|
217
|
+
const idsTip = git.ref(ctx.config.ids_ref);
|
|
218
|
+
if (idsTip !== null) {
|
|
219
|
+
const ir = git.push(r.name, [`${ctx.config.ids_ref}:${ctx.config.ids_ref}`]);
|
|
220
|
+
if (ir.status === "ok") {
|
|
221
|
+
git.setRef(idsTrackingRef(r), idsTip);
|
|
222
|
+
ids = "pushed";
|
|
223
|
+
}
|
|
224
|
+
else if (ir.status === "rejected")
|
|
225
|
+
ids = "rejected";
|
|
226
|
+
else
|
|
227
|
+
throw new Error(`push of ${ctx.config.ids_ref} to ${r.name} failed: ${ir.stderr}`);
|
|
228
|
+
}
|
|
229
|
+
return { pushed: before !== local, tip: local, pushed_through: seq, ids };
|
|
230
|
+
}
|
|
231
|
+
/** The per-path rule of spec/store §5.12 "Merge": one side changed → take it; both to the same blob → take it; both to different blobs → conflict. */
|
|
232
|
+
function threeWay(git, base, ours, theirs) {
|
|
233
|
+
const baseTree = base ?? EMPTY_TREE;
|
|
234
|
+
const ourChanges = git.diffTrees(baseTree, ours);
|
|
235
|
+
const theirChanges = git.diffTrees(baseTree, theirs);
|
|
236
|
+
const theirsOnly = new Map();
|
|
237
|
+
const conflicts = [];
|
|
238
|
+
for (const [path, blob] of theirChanges) {
|
|
239
|
+
if (!ourChanges.has(path))
|
|
240
|
+
theirsOnly.set(path, blob);
|
|
241
|
+
else if (ourChanges.get(path) !== blob)
|
|
242
|
+
conflicts.push(path);
|
|
243
|
+
}
|
|
244
|
+
conflicts.sort();
|
|
245
|
+
return { theirsOnly, conflicts };
|
|
246
|
+
}
|
|
247
|
+
function blobChanges(git, changes) {
|
|
248
|
+
return [...changes].sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0)).map(([path, blob]) => ({ path, content: blob === null ? null : git.blob(blob) }));
|
|
249
|
+
}
|
|
250
|
+
/** The position the worker will resume from after the branch moves: the current resume position, else the feed head before anything is ingested. */
|
|
251
|
+
function positionBeforeApply(ctx, epoch) {
|
|
252
|
+
return resumePosition(ctx, epoch)?.seq ?? feedStatus(ctx.store.db, ctx.repoId).head;
|
|
253
|
+
}
|
|
254
|
+
function mergeTrailers(ctx, epoch, seq, now, remoteTip) {
|
|
255
|
+
return { epoch, from: seq, to: seq, ts: now, commits: [], origin: null, actor: null, plan: null, docs: [], rescan: null, merge: remoteTip, content: null };
|
|
256
|
+
}
|
|
257
|
+
/** The merge commit: `tree` over parents (ours, theirs), subject `merge <name>/<rb>`, trailer `omgbase-merge: <remote tip>`. */
|
|
258
|
+
function writeMergeCommit(ctx, r, tree, ours, theirs, seq, now) {
|
|
259
|
+
const epoch = repoEpoch(ctx.store.db, ctx.repoId);
|
|
260
|
+
const message = renderMessage(`merge ${r.name}/${r.branch}`, mergeTrailers(ctx, epoch, seq, now, theirs));
|
|
261
|
+
const sha = ctx.git.commitTree(tree, [ours, theirs], message, { author: ctx.identity, committer: ctx.identity, ts: now });
|
|
262
|
+
ctx.git.updateRef(ctx.config.branch, sha, ours);
|
|
263
|
+
writeGitCursor(ctx.store.db, ctx.repoId, { tip: sha, epoch, seq });
|
|
264
|
+
return sha;
|
|
265
|
+
}
|
|
266
|
+
/** Record a merge with conflicts: the remote's versions as sidecars in the vault, the pending ref, the alert. */
|
|
267
|
+
function recordConflicts(ctx, r, remoteTip, conflicts, now, extra = {}) {
|
|
268
|
+
ctx.git.setRef(mergeRef(r), remoteTip);
|
|
269
|
+
const detail = { remote: r.name, branch: r.branch, remote_tip: remoteTip, paths: conflicts, ...extra };
|
|
270
|
+
raiseAlert(ctx.store.db, ctx.repoId, { code: GIT_MERGE_CONFLICT_ALERT, severity: "warning", message: conflictMessage(r.name, r.branch, remoteTip, conflicts), detail }, now);
|
|
271
|
+
}
|
|
272
|
+
/** The remote's versions of the conflicting paths as sidecars (`<stem>.remote-<short>.md`); a path the remote deleted gets no sidecar. */
|
|
273
|
+
function remoteSidecars(git, remoteTip, conflicts) {
|
|
274
|
+
const theirs = git.treeEntries(remoteTip);
|
|
275
|
+
const out = [];
|
|
276
|
+
for (const path of conflicts) {
|
|
277
|
+
const blob = theirs.get(path);
|
|
278
|
+
if (blob !== undefined)
|
|
279
|
+
out.push({ path: sidecarPath(path, "remote", remoteTip), content: git.blob(blob) });
|
|
280
|
+
}
|
|
281
|
+
return out;
|
|
282
|
+
}
|
|
283
|
+
/**
|
|
284
|
+
* `history fetch` (spec/store §5.12 "Fetch"): fetch `<name>/<rb>` into the
|
|
285
|
+
* tracking ref (and the remote's ids ref when it has one), then — nothing new:
|
|
286
|
+
* `nothing`; the remote rewritten (its tip does not descend from the tip we
|
|
287
|
+
* last knew, or unrelated histories): `diverged` — the alert, the old tip
|
|
288
|
+
* kept, push and fetch paused; we are behind: the remote's tree changes into
|
|
289
|
+
* the vault through the sink, then our branch fast-forwarded; both moved: the
|
|
290
|
+
* three-way merge — clean: the remote's changes into the vault and one merge
|
|
291
|
+
* commit; conflicts: the remote's versions beside ours, the alert, no merge
|
|
292
|
+
* commit until `resolve`.
|
|
293
|
+
*/
|
|
294
|
+
export function fetchRemote(ctx, now, vault) {
|
|
295
|
+
const r = requireRemote(ctx);
|
|
296
|
+
const paused = pausedBy(ctx);
|
|
297
|
+
if (paused)
|
|
298
|
+
throw pausedError(paused);
|
|
299
|
+
const pending = pendingMergeError(ctx, r);
|
|
300
|
+
if (pending)
|
|
301
|
+
throw pending;
|
|
302
|
+
const git = ctx.git;
|
|
303
|
+
git.init(ctx.config.branch);
|
|
304
|
+
const epoch = repoEpoch(ctx.store.db, ctx.repoId);
|
|
305
|
+
const local = git.tip(ctx.config.branch);
|
|
306
|
+
const old = git.ref(trackingRef(r));
|
|
307
|
+
const remoteHas = git.lsRemote(r.name, `refs/heads/${r.branch}`);
|
|
308
|
+
if (remoteHas === null)
|
|
309
|
+
return { state: "nothing", remote_tip: null, tip: local, applied: [], conflicts: [] };
|
|
310
|
+
const err = git.fetch(r.name, [`+refs/heads/${r.branch}:${trackingRef(r)}`]);
|
|
311
|
+
if (err !== null)
|
|
312
|
+
throw new Error(`fetch from ${r.name} failed: ${err}`);
|
|
313
|
+
if (git.lsRemote(r.name, ctx.config.ids_ref) !== null)
|
|
314
|
+
git.fetch(r.name, [`+${ctx.config.ids_ref}:${idsTrackingRef(r)}`]);
|
|
315
|
+
const remoteTip = git.ref(trackingRef(r));
|
|
316
|
+
// A rejected push's alert is about a remote we have now fetched: it ends here (the merge below settles it, or the divergence alert replaces it).
|
|
317
|
+
clearAlert(ctx.store.db, ctx.repoId, GIT_REMOTE_DIVERGED_ALERT, now);
|
|
318
|
+
if (remoteTip === old || remoteTip === local)
|
|
319
|
+
return { state: "nothing", remote_tip: remoteTip, tip: local, applied: [], conflicts: [] };
|
|
320
|
+
const rewritten = old !== null ? !git.isAncestor(old, remoteTip) : local !== null && git.mergeBase(local, remoteTip) === null;
|
|
321
|
+
if (rewritten) {
|
|
322
|
+
if (old !== null)
|
|
323
|
+
git.setRef(divergedRef(r), old);
|
|
324
|
+
const detail = { remote: r.name, branch: r.branch, local_tip: local, remote_tip: remoteTip, old_remote_tip: old };
|
|
325
|
+
raiseAlert(ctx.store.db, ctx.repoId, { code: GIT_REMOTE_DIVERGED_ALERT, severity: "warning", message: rewrittenMessage(r.name, r.branch, remoteTip, old), detail: { ...detail } }, now);
|
|
326
|
+
return { state: "diverged", remote_tip: remoteTip, old_remote_tip: old, tip: local, applied: [], conflicts: [] };
|
|
327
|
+
}
|
|
328
|
+
if (local === null || git.isAncestor(local, remoteTip)) {
|
|
329
|
+
// Behind: the remote's changes enter through the vault, then the branch follows.
|
|
330
|
+
const seq = positionBeforeApply(ctx, epoch);
|
|
331
|
+
const changes = blobChanges(git, git.diffTrees(local ?? EMPTY_TREE, remoteTip));
|
|
332
|
+
vault.apply(changes, now);
|
|
333
|
+
git.updateRef(ctx.config.branch, remoteTip, local);
|
|
334
|
+
writeGitCursor(ctx.store.db, ctx.repoId, { tip: remoteTip, epoch, seq });
|
|
335
|
+
return { state: "fast-forward", remote_tip: remoteTip, tip: remoteTip, applied: changes.map((c) => c.path), conflicts: [] };
|
|
336
|
+
}
|
|
337
|
+
if (git.isAncestor(remoteTip, local))
|
|
338
|
+
return { state: "nothing", remote_tip: remoteTip, tip: local, applied: [], conflicts: [] }; // ahead: a push is due, nothing to merge
|
|
339
|
+
// Both moved: the three-way merge at the tree level with the ancestor git supplies.
|
|
340
|
+
const base = git.mergeBase(local, remoteTip);
|
|
341
|
+
const { theirsOnly, conflicts } = threeWay(git, base, local, remoteTip);
|
|
342
|
+
const seq = positionBeforeApply(ctx, epoch);
|
|
343
|
+
const changes = [...blobChanges(git, theirsOnly), ...remoteSidecars(git, remoteTip, conflicts)];
|
|
344
|
+
vault.apply(changes, now);
|
|
345
|
+
if (conflicts.length > 0) {
|
|
346
|
+
recordConflicts(ctx, r, remoteTip, conflicts, now, { base });
|
|
347
|
+
return { state: "conflict", remote_tip: remoteTip, tip: local, applied: changes.map((c) => c.path), conflicts };
|
|
348
|
+
}
|
|
349
|
+
const tree = git.buildTree(git.treeOf(local), [...theirsOnly].filter(([, b]) => b !== null).map(([path, blob]) => ({ path, blob: blob })), [...theirsOnly].filter(([, b]) => b === null).map(([p]) => p));
|
|
350
|
+
const merged = writeMergeCommit(ctx, r, tree, local, remoteTip, seq, now);
|
|
351
|
+
return { state: "merged", remote_tip: remoteTip, tip: merged, applied: changes.map((c) => c.path), conflicts: [] };
|
|
352
|
+
}
|
|
353
|
+
// ---- resolve ----
|
|
354
|
+
/**
|
|
355
|
+
* `history remote resolve <path> --ours|--theirs`: the sidecar leaves the
|
|
356
|
+
* vault (`--theirs` first writes the remote's version over ours); the path
|
|
357
|
+
* leaves the alert; when none remains the pending merge — if one waits — is
|
|
358
|
+
* committed over the resolution the worker just checkpointed.
|
|
359
|
+
*/
|
|
360
|
+
export function resolveConflict(ctx, now, vault, path, choice) {
|
|
361
|
+
const r = requireRemote(ctx);
|
|
362
|
+
const conflict = activeAlert(ctx, GIT_MERGE_CONFLICT_ALERT);
|
|
363
|
+
if (!conflict)
|
|
364
|
+
throw new Error("no merge conflict is pending for this repository");
|
|
365
|
+
const paths = conflict.detail.paths ?? [];
|
|
366
|
+
if (!paths.includes(path))
|
|
367
|
+
throw new Error(`${path} is not a pending conflict (pending: ${paths.join(", ")})`);
|
|
368
|
+
const remoteTip = conflict.detail.remote_tip ?? null;
|
|
369
|
+
const holds = (conflict.detail.holds ?? {})[path] ?? "theirs";
|
|
370
|
+
const sidecar = (conflict.detail.sidecars ?? {})[path] ?? (remoteTip ? sidecarPath(path, "remote", remoteTip) : null);
|
|
371
|
+
const changes = [];
|
|
372
|
+
const git = ctx.git;
|
|
373
|
+
if (holds === "theirs") {
|
|
374
|
+
// The vault holds OURS at the path, THEIRS in the sidecar.
|
|
375
|
+
if (choice === "theirs" && remoteTip) {
|
|
376
|
+
const blob = git.treeEntries(remoteTip).get(path);
|
|
377
|
+
changes.push({ path, content: blob === undefined ? null : git.blob(blob) });
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
else if (choice === "ours" && sidecar) {
|
|
381
|
+
// `--adopt` left THEIRS at the path, OURS in the sidecar: ours comes back.
|
|
382
|
+
const live = ctx.store.db.prepare("SELECT doc_id FROM docs WHERE repo_id = ? AND path = ? AND deleted_at IS NULL").get(ctx.repoId, sidecar);
|
|
383
|
+
const bytes = live ? reconstructContent(ctx.store.db, live.doc_id) : null;
|
|
384
|
+
if (bytes !== null)
|
|
385
|
+
changes.push({ path, content: Buffer.from(bytes) });
|
|
386
|
+
}
|
|
387
|
+
if (sidecar)
|
|
388
|
+
changes.push({ path: sidecar, content: null });
|
|
389
|
+
vault.apply(changes, now);
|
|
390
|
+
const remaining = paths.filter((p) => p !== path);
|
|
391
|
+
let merged = null;
|
|
392
|
+
if (remaining.length > 0) {
|
|
393
|
+
raiseAlert(ctx.store.db, ctx.repoId, { code: GIT_MERGE_CONFLICT_ALERT, severity: "warning", message: conflictMessage(r.name, r.branch, remoteTip, remaining), detail: { ...conflict.detail, paths: remaining } }, now);
|
|
394
|
+
}
|
|
395
|
+
else {
|
|
396
|
+
clearAlert(ctx.store.db, ctx.repoId, GIT_MERGE_CONFLICT_ALERT, now);
|
|
397
|
+
const waiting = git.ref(mergeRef(r));
|
|
398
|
+
if (waiting !== null) {
|
|
399
|
+
// The resolution is checkpointed first, so the merge commit's tree is the vault's.
|
|
400
|
+
runCheckpoint(ctx, { now });
|
|
401
|
+
const tip = git.tip(ctx.config.branch);
|
|
402
|
+
const seq = resumePosition(ctx, repoEpoch(ctx.store.db, ctx.repoId))?.seq ?? feedStatus(ctx.store.db, ctx.repoId).head;
|
|
403
|
+
merged = writeMergeCommit(ctx, r, git.treeOf(tip), tip, waiting, seq, now);
|
|
404
|
+
git.deleteRef(mergeRef(r));
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
return { path, choice, remaining, merged, tip: git.tip(ctx.config.branch) };
|
|
408
|
+
}
|
|
409
|
+
// ---- the divergence remediations ----
|
|
410
|
+
function requireDivergence(ctx) {
|
|
411
|
+
const paused = pausedBy(ctx);
|
|
412
|
+
if (!paused)
|
|
413
|
+
throw new Error("the remote is not diverged: history remote reconcile applies only while the git_remote_diverged (or git_remote_forked) alert is active");
|
|
414
|
+
return { detail: paused.detail };
|
|
415
|
+
}
|
|
416
|
+
function clearDivergence(ctx, r, now) {
|
|
417
|
+
clearAlert(ctx.store.db, ctx.repoId, GIT_REMOTE_DIVERGED_ALERT, now);
|
|
418
|
+
clearAlert(ctx.store.db, ctx.repoId, GIT_REMOTE_FORKED_ALERT, now);
|
|
419
|
+
ctx.git.deleteRef(divergedRef(r));
|
|
420
|
+
}
|
|
421
|
+
function backup(ctx, tip, now) {
|
|
422
|
+
ctx.git.setRef(`refs/omgbase/backup/${tsSegment(now)}`, tip);
|
|
423
|
+
return tip;
|
|
424
|
+
}
|
|
425
|
+
/** `history remote reconcile --rebase|--merge|--adopt|--fork`. */
|
|
426
|
+
export function reconcileRemote(ctx, now, vault, mode) {
|
|
427
|
+
const r = requireRemote(ctx);
|
|
428
|
+
const { detail } = requireDivergence(ctx);
|
|
429
|
+
const git = ctx.git;
|
|
430
|
+
const epoch = repoEpoch(ctx.store.db, ctx.repoId);
|
|
431
|
+
const local = git.tip(ctx.config.branch);
|
|
432
|
+
const remoteTip = git.ref(trackingRef(r)) ?? detail.remote_tip ?? null;
|
|
433
|
+
const old = git.ref(divergedRef(r)) ?? detail.old_remote_tip ?? null;
|
|
434
|
+
if (local === null || remoteTip === null)
|
|
435
|
+
throw new Error("nothing to reconcile: the branch or the remote has no tip");
|
|
436
|
+
const result = { mode, tip: local, backup: null, rewritten: 0, fork: null, applied: [], conflicts: [] };
|
|
437
|
+
if (mode === "fork") {
|
|
438
|
+
const fork = `${r.branch}-omgbase-${tsSegment(now)}`;
|
|
439
|
+
const pushed = git.push(r.name, [`refs/heads/${ctx.config.branch}:refs/heads/${fork}`]);
|
|
440
|
+
if (pushed.status !== "ok")
|
|
441
|
+
throw new Error(`push of the fork ${fork} to ${r.name} failed: ${pushed.stderr}`);
|
|
442
|
+
clearDivergence(ctx, r, now);
|
|
443
|
+
raiseAlert(ctx.store.db, ctx.repoId, { code: GIT_REMOTE_FORKED_ALERT, severity: "warning", message: forkedMessage(r.name, r.branch, fork), detail: { remote: r.name, branch: r.branch, fork, local_tip: local, remote_tip: remoteTip, old_remote_tip: old } }, now);
|
|
444
|
+
return { ...result, fork };
|
|
445
|
+
}
|
|
446
|
+
if (mode === "adopt") {
|
|
447
|
+
// The remote's head becomes truth: its tree into the vault; a path we edited since the last agreement keeps our version beside it.
|
|
448
|
+
const theirs = git.treeEntries(remoteTip);
|
|
449
|
+
const ours = git.treeEntries(local);
|
|
450
|
+
const base = old !== null ? git.treeEntries(old) : new Map();
|
|
451
|
+
const changes = [];
|
|
452
|
+
const conflicts = [];
|
|
453
|
+
const sidecars = {};
|
|
454
|
+
const holds = {};
|
|
455
|
+
for (const path of [...new Set([...theirs.keys(), ...ours.keys()])].sort()) {
|
|
456
|
+
const t = theirs.get(path) ?? null;
|
|
457
|
+
const o = ours.get(path) ?? null;
|
|
458
|
+
const b = base.get(path) ?? null;
|
|
459
|
+
if (t === o)
|
|
460
|
+
continue;
|
|
461
|
+
if (o === b) {
|
|
462
|
+
changes.push({ path, content: t === null ? null : git.blob(t) });
|
|
463
|
+
continue;
|
|
464
|
+
} // only the remote changed it
|
|
465
|
+
if (t === null && b === null)
|
|
466
|
+
continue; // ours alone, new on our side: kept, committed on top
|
|
467
|
+
// A local edit the remote does not have: the remote's version takes the path, ours stays beside it.
|
|
468
|
+
const side = sidecarPath(path, "local", local);
|
|
469
|
+
changes.push({ path, content: t === null ? null : git.blob(t) });
|
|
470
|
+
if (o !== null)
|
|
471
|
+
changes.push({ path: side, content: git.blob(o) });
|
|
472
|
+
conflicts.push(path);
|
|
473
|
+
sidecars[path] = side;
|
|
474
|
+
holds[path] = "ours";
|
|
475
|
+
}
|
|
476
|
+
const seq = positionBeforeApply(ctx, epoch);
|
|
477
|
+
vault.apply(changes, now);
|
|
478
|
+
result.backup = backup(ctx, local, now);
|
|
479
|
+
git.updateRef(ctx.config.branch, remoteTip, local);
|
|
480
|
+
writeGitCursor(ctx.store.db, ctx.repoId, { tip: remoteTip, epoch, seq });
|
|
481
|
+
clearDivergence(ctx, r, now);
|
|
482
|
+
if (conflicts.length > 0) {
|
|
483
|
+
const d = { remote: r.name, branch: r.branch, remote_tip: remoteTip, paths: conflicts, sidecars, holds };
|
|
484
|
+
raiseAlert(ctx.store.db, ctx.repoId, { code: GIT_MERGE_CONFLICT_ALERT, severity: "warning", message: conflictMessage(r.name, r.branch, remoteTip, conflicts), detail: d }, now);
|
|
485
|
+
}
|
|
486
|
+
return { ...result, tip: remoteTip, applied: changes.map((c) => c.path), conflicts };
|
|
487
|
+
}
|
|
488
|
+
if (mode === "merge") {
|
|
489
|
+
// One merge commit with the old remote tip as the base (what both sides last agreed on); both histories kept.
|
|
490
|
+
const base = old ?? git.mergeBase(local, remoteTip);
|
|
491
|
+
const { theirsOnly, conflicts } = threeWay(git, base, local, remoteTip);
|
|
492
|
+
const seq = positionBeforeApply(ctx, epoch);
|
|
493
|
+
const changes = [...blobChanges(git, theirsOnly), ...remoteSidecars(git, remoteTip, conflicts)];
|
|
494
|
+
vault.apply(changes, now);
|
|
495
|
+
clearDivergence(ctx, r, now);
|
|
496
|
+
if (conflicts.length > 0) {
|
|
497
|
+
recordConflicts(ctx, r, remoteTip, conflicts, now, { base });
|
|
498
|
+
return { ...result, applied: changes.map((c) => c.path), conflicts };
|
|
499
|
+
}
|
|
500
|
+
result.backup = backup(ctx, local, now);
|
|
501
|
+
const tree = git.buildTree(git.treeOf(local), [...theirsOnly].filter(([, b]) => b !== null).map(([path, blob]) => ({ path, blob: blob })), [...theirsOnly].filter(([, b]) => b === null).map(([p]) => p));
|
|
502
|
+
const merged = writeMergeCommit(ctx, r, tree, local, remoteTip, seq, now);
|
|
503
|
+
return { ...result, tip: merged, rewritten: 1, applied: changes.map((c) => c.path), conflicts: [] };
|
|
504
|
+
}
|
|
505
|
+
// --rebase: replay our unpushed checkpoints onto the new remote head, per file three-way with the old remote tip as base.
|
|
506
|
+
const oursCommits = git.revList(old, local);
|
|
507
|
+
let running = remoteTip;
|
|
508
|
+
const conflicts = new Set();
|
|
509
|
+
const rewrittenMap = new Map();
|
|
510
|
+
for (const c of oursCommits) {
|
|
511
|
+
const parents = git.parentsOf(c);
|
|
512
|
+
const firstParent = parents[0] ?? null;
|
|
513
|
+
const diff = git.diffTrees(firstParent ?? EMPTY_TREE, c);
|
|
514
|
+
const parentTree = firstParent ? git.treeEntries(firstParent) : new Map();
|
|
515
|
+
const runningTree = git.treeEntries(running);
|
|
516
|
+
const adds = [];
|
|
517
|
+
const removes = [];
|
|
518
|
+
for (const [path, blob] of diff) {
|
|
519
|
+
const current = runningTree.get(path) ?? null;
|
|
520
|
+
const before = parentTree.get(path) ?? null;
|
|
521
|
+
if (current === blob)
|
|
522
|
+
continue; // the remote did the same
|
|
523
|
+
if (current !== before)
|
|
524
|
+
conflicts.add(path); // the remote changed it too: ours stands, theirs beside it (below)
|
|
525
|
+
if (blob === null) {
|
|
526
|
+
if (current !== null)
|
|
527
|
+
removes.push(path);
|
|
528
|
+
}
|
|
529
|
+
else
|
|
530
|
+
adds.push({ path, blob });
|
|
531
|
+
}
|
|
532
|
+
const tree = adds.length === 0 && removes.length === 0 ? git.treeOf(running) : git.buildTree(git.treeOf(running), adds, removes);
|
|
533
|
+
const message = git.message(c).replace(/\n$/, "");
|
|
534
|
+
const trailers = parseMessage(message);
|
|
535
|
+
const author = git.authorOf(c);
|
|
536
|
+
const newParents = [running, ...parents.slice(1).map((p) => rewrittenMap.get(p) ?? p)];
|
|
537
|
+
const sha = git.commitTree(tree, newParents, message, { author: { name: author.name, email: ctx.identity.email }, committer: ctx.identity, ts: trailers?.ts || now });
|
|
538
|
+
rewrittenMap.set(c, sha);
|
|
539
|
+
running = sha;
|
|
540
|
+
}
|
|
541
|
+
// The remote's changes since the old tip that we never touched enter the vault; the conflicting paths' remote versions beside ours.
|
|
542
|
+
const { theirsOnly } = threeWay(git, old, local, remoteTip);
|
|
543
|
+
const conflictPaths = [...conflicts].sort();
|
|
544
|
+
const changes = [...blobChanges(git, theirsOnly), ...remoteSidecars(git, remoteTip, conflictPaths)];
|
|
545
|
+
const seq = positionBeforeApply(ctx, epoch);
|
|
546
|
+
vault.apply(changes, now);
|
|
547
|
+
result.backup = backup(ctx, local, now);
|
|
548
|
+
git.updateRef(ctx.config.branch, running, local);
|
|
549
|
+
writeGitCursor(ctx.store.db, ctx.repoId, { tip: running, epoch, seq });
|
|
550
|
+
rebaseIds(ctx, r, rewrittenMap, now);
|
|
551
|
+
clearDivergence(ctx, r, now);
|
|
552
|
+
if (conflictPaths.length > 0) {
|
|
553
|
+
const d = { remote: r.name, branch: r.branch, remote_tip: remoteTip, paths: conflictPaths };
|
|
554
|
+
raiseAlert(ctx.store.db, ctx.repoId, { code: GIT_MERGE_CONFLICT_ALERT, severity: "warning", message: conflictMessage(r.name, r.branch, remoteTip, conflictPaths), detail: d }, now);
|
|
555
|
+
}
|
|
556
|
+
return { ...result, tip: running, rewritten: oursCommits.length, applied: changes.map((c) => c.path), conflicts: conflictPaths };
|
|
557
|
+
}
|
|
558
|
+
/**
|
|
559
|
+
* The ids ref rebased alongside: every ids commit whose `omgbase-content`
|
|
560
|
+
* names a rewritten content commit is replayed — its tree changes over the
|
|
561
|
+
* remote's ids tip when we have fetched one (else over its own base) — with
|
|
562
|
+
* the trailer re-pointed at the rewritten sha. Identities are ours
|
|
563
|
+
* throughout; the remote's ids chain, when it differs, is 6c's reconciliation.
|
|
564
|
+
*/
|
|
565
|
+
function rebaseIds(ctx, r, rewritten, now) {
|
|
566
|
+
const git = ctx.git;
|
|
567
|
+
const idsTip = git.ref(ctx.config.ids_ref);
|
|
568
|
+
if (idsTip === null || rewritten.size === 0)
|
|
569
|
+
return;
|
|
570
|
+
const chain = git.revList(null, idsTip);
|
|
571
|
+
const first = chain.findIndex((c) => { const t = parseMessage(git.message(c)); return t?.content !== null && t?.content !== undefined && rewritten.has(t.content); });
|
|
572
|
+
if (first < 0)
|
|
573
|
+
return;
|
|
574
|
+
const replay = chain.slice(first);
|
|
575
|
+
const base = git.ref(idsTrackingRef(r)) ?? git.parentsOf(replay[0])[0] ?? null;
|
|
576
|
+
let running = base;
|
|
577
|
+
for (const c of replay) {
|
|
578
|
+
const parent = git.parentsOf(c)[0] ?? null;
|
|
579
|
+
const diff = git.diffTrees(parent ?? EMPTY_TREE, c);
|
|
580
|
+
const adds = [...diff].filter(([, b]) => b !== null).map(([path, blob]) => ({ path, blob: blob }));
|
|
581
|
+
const removes = [...diff].filter(([, b]) => b === null).map(([p]) => p);
|
|
582
|
+
const runningTree = running === null ? null : git.treeOf(running);
|
|
583
|
+
const tree = git.buildTree(runningTree, adds, removes.filter((p) => running !== null && git.treeEntries(running).has(p)));
|
|
584
|
+
const message = git.message(c).replace(/\n$/, "").replace(/^omgbase-content: ([0-9a-f]{40})$/m, (_m, sha) => `omgbase-content: ${rewritten.get(sha) ?? sha}`);
|
|
585
|
+
const t = parseMessage(message);
|
|
586
|
+
const author = git.authorOf(c);
|
|
587
|
+
running = git.commitTree(tree, running, message, { author: { name: author.name, email: ctx.identity.email }, committer: ctx.identity, ts: t?.ts || now });
|
|
588
|
+
}
|
|
589
|
+
if (running !== null)
|
|
590
|
+
git.updateRefRaw(ctx.config.ids_ref, running, idsTip);
|
|
591
|
+
}
|
|
592
|
+
// ---- the sweep's steps 7–8 ----
|
|
593
|
+
/** `remoteSync` of the provider seam (spec/sync §4.3 steps 7–8): push on `periodic`, fetch on `periodic`; both skipped while paused or while a merge waits. */
|
|
594
|
+
export function remoteSync(ctx, now, vault) {
|
|
595
|
+
const r = ctx.config.remote;
|
|
596
|
+
if (!r)
|
|
597
|
+
return null;
|
|
598
|
+
if (pausedBy(ctx) !== null || activeAlert(ctx, GIT_MERGE_CONFLICT_ALERT) !== null)
|
|
599
|
+
return { pushed: null, fetched: null, paused: true };
|
|
600
|
+
const out = { pushed: null, fetched: null, paused: false };
|
|
601
|
+
if (r.fetch === "periodic") {
|
|
602
|
+
try {
|
|
603
|
+
out.fetched = fetchRemote(ctx, now, vault);
|
|
604
|
+
}
|
|
605
|
+
catch (e) {
|
|
606
|
+
out.fetched = { error: e instanceof Error ? e.message : String(e) };
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
if (r.push === "periodic") {
|
|
610
|
+
try {
|
|
611
|
+
out.pushed = pushBranch(ctx, now);
|
|
612
|
+
}
|
|
613
|
+
catch (e) {
|
|
614
|
+
out.pushed = { error: e instanceof Error ? e.message : String(e) };
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
return out;
|
|
618
|
+
}
|
|
619
|
+
/** `push: "on-checkpoint"`: after a checkpoint that committed, the push — its error reported on the receipt, never thrown (the checkpoint stands). */
|
|
620
|
+
export function pushAfterCheckpoint(ctx, now, committed) {
|
|
621
|
+
const r = ctx.config.remote;
|
|
622
|
+
if (!r || r.push !== "on-checkpoint" || committed === 0)
|
|
623
|
+
return undefined;
|
|
624
|
+
try {
|
|
625
|
+
return pushBranch(ctx, now);
|
|
626
|
+
}
|
|
627
|
+
catch (e) {
|
|
628
|
+
return { error: e instanceof Error ? e.message : String(e) };
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
//# sourceMappingURL=remote.js.map
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { GitCadence } from "./config.js";
|
|
2
|
+
export interface SchedulerOptions {
|
|
3
|
+
cadence: GitCadence;
|
|
4
|
+
/** run the worker once (the host wraps it in the writer lock when the hook fires outside one) */
|
|
5
|
+
run: () => void;
|
|
6
|
+
onError?: (err: unknown) => void;
|
|
7
|
+
setTimeout?: (fn: () => void, ms: number) => unknown;
|
|
8
|
+
clearTimeout?: (handle: unknown) => void;
|
|
9
|
+
}
|
|
10
|
+
export interface CheckpointScheduler {
|
|
11
|
+
/** the mutation hook */
|
|
12
|
+
notify(): void;
|
|
13
|
+
/** run a pending debounced checkpoint now */
|
|
14
|
+
flush(): void;
|
|
15
|
+
/** cancel a pending timer */
|
|
16
|
+
close(): void;
|
|
17
|
+
/** is a debounced run pending? */
|
|
18
|
+
pending(): boolean;
|
|
19
|
+
}
|
|
20
|
+
export declare function createCheckpointScheduler(opts: SchedulerOptions): CheckpointScheduler;
|