hivecode-mcp 0.14.1 → 0.14.2
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/HIVE_RULES.md +98 -98
- package/LICENSE +21 -21
- package/README.md +421 -421
- package/core.js +91 -91
- package/docs/ALWAYS_ON.md +310 -310
- package/docs/GITHUB_WORKFLOW.md +394 -394
- package/docs/HIVE_RULES.md +41 -41
- package/docs/MCP.md +157 -157
- package/docs/QUICKSTART.md +266 -266
- package/docs/SELF_HOSTING.md +256 -256
- package/docs/SLACK_SETUP.md +474 -474
- package/docs/TROUBLESHOOTING.md +105 -105
- package/hive-activity.js +336 -336
- package/hive-authors.js +46 -44
- package/hive-await.js +230 -230
- package/hive-branch.js +608 -607
- package/hive-busy.js +88 -88
- package/hive-coord.js +110 -110
- package/hive-gate.js +135 -135
- package/hive-github.js +3 -1
- package/hive-hook-install.js +179 -179
- package/hive-instructions.js +74 -74
- package/hive-land.js +695 -691
- package/hive-listen.js +81 -81
- package/hive-mailbox.js +520 -520
- package/hive-mcp-http.js +591 -591
- package/hive-mcp.js +33 -6
- package/hive-mux-proto.js +58 -58
- package/hive-mux.js +351 -351
- package/hive-notify.js +234 -233
- package/hive-obligations.js +247 -247
- package/hive-poll-proto.js +50 -50
- package/hive-pr-staleness.js +189 -189
- package/hive-pr.js +600 -512
- package/hive-privacy.js +327 -327
- package/hive-receipts.js +233 -233
- package/hive-relay-health.js +234 -234
- package/hive-repos.js +430 -430
- package/hive-room-key.js +210 -210
- package/hive-room-mode.js +1668 -1668
- package/hive-room-watcher.js +562 -561
- package/hive-secrets.js +89 -89
- package/hive-session.js +114 -114
- package/hive-setup.js +560 -560
- package/hive-slack-agent.js +408 -408
- package/hive-slack.js +2384 -2367
- package/hive-stdio.js +172 -172
- package/hive-stop-hook.js +293 -293
- package/hive-throttle.js +118 -118
- package/hive-time.js +71 -71
- package/hive-transport.js +578 -578
- package/hive-update.js +58 -58
- package/hive-version.js +110 -110
- package/hive-webhook.js +55 -54
- package/icr-merge.js +58 -58
- package/icr.js +851 -851
- package/lang-brace.js +305 -305
- package/lang-js.js +651 -651
- package/lang-json.js +211 -211
- package/lang-python.js +588 -588
- package/lang-ruby.js +230 -230
- package/lang-toml.js +136 -136
- package/lang-yaml.js +183 -183
- package/limits.js +413 -413
- package/package.json +1 -1
- package/reconcile.cjs +621 -621
- package/slack-app-manifest-agent.json +61 -61
- package/slack-app-manifest-inbound.json +36 -36
- package/slack-app-manifest.json +27 -27
- package/substrate.js +252 -252
- package/sync.js +169 -11
- package/token.js +201 -201
package/reconcile.cjs
CHANGED
|
@@ -1,621 +1,621 @@
|
|
|
1
|
-
// reconcile.cjs — Single source of truth for the 3-way reconcile & merge engine.
|
|
2
|
-
//
|
|
3
|
-
// CommonJS on purpose: sync.js is ESM and the VS Code extension is CJS.
|
|
4
|
-
// A .cjs file is the one shape both can load synchronously without duplication.
|
|
5
|
-
//
|
|
6
|
-
// Acceptance criteria (HIV-8), re-checked 21 Sep 2026 -- FOUR OF FIVE MET:
|
|
7
|
-
// 1. MET one module exports reconcile() and its dependencies (11 exports).
|
|
8
|
-
// 2. MET core.js re-exports from here and sync.js imports core.js. One copy.
|
|
9
|
-
// 3. NOT MET extension/extension.js does NOT require this file. It requires
|
|
10
|
-
// ./icr.cjs (the ICR bundle) and keeps its OWN reconcile() at
|
|
11
|
-
// extension/extension.js:1166. The second copy is still there.
|
|
12
|
-
// 4. MET node tests/test.js -- 95 passed, 0 failed.
|
|
13
|
-
// 5. MET node tests/conflict-test.mjs -- ALL 4 PASS.
|
|
14
|
-
//
|
|
15
|
-
// Criterion 3 read "No second copy anywhere" while a second copy existed, in a file
|
|
16
|
-
// that ships in package.json files[]. A comment asserting an invariant it does not
|
|
17
|
-
// hold is worse than no comment: the next reader trusts it and stops looking. Corrected
|
|
18
|
-
// to what is true rather than deleted, because the gap is the useful part.
|
|
19
|
-
//
|
|
20
|
-
// WHAT ACTUALLY GUARDS THE DRIFT, since it is not this file:
|
|
21
|
-
// tests/extension-parity-test.mjs the ICR bundle vs the engine, 24/24
|
|
22
|
-
// tests/extension-reconcile-characterisation-test.mjs pins the extension's own copy
|
|
23
|
-
// Those two measure that the copies AGREE. They do not make it one copy, and closing
|
|
24
|
-
// criterion 3 for real means the extension loading this module.
|
|
25
|
-
|
|
26
|
-
const fs = require('fs')
|
|
27
|
-
const path = require('path')
|
|
28
|
-
|
|
29
|
-
// Minimal text diff: replace only the changed middle, preserving the common
|
|
30
|
-
// prefix/suffix. Applied to a Y.Text so edits stay small (good for CRDT churn).
|
|
31
|
-
function applyDiff(ytext, next) {
|
|
32
|
-
const cur = ytext.toString()
|
|
33
|
-
if (cur === next) return false
|
|
34
|
-
let p = 0
|
|
35
|
-
while (p < cur.length && p < next.length && cur[p] === next[p]) p++
|
|
36
|
-
let s = 0
|
|
37
|
-
while (s < cur.length - p && s < next.length - p && cur[cur.length - 1 - s] === next[next.length - 1 - s]) s++
|
|
38
|
-
if (cur.length - p - s > 0) ytext.delete(p, cur.length - p - s)
|
|
39
|
-
const ins = next.slice(p, next.length - s)
|
|
40
|
-
if (ins) ytext.insert(p, ins)
|
|
41
|
-
return true
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
// Detect REAL git-style conflict markers in a file. Line-anchored on purpose.
|
|
45
|
-
function hasConflictMarkers(text) {
|
|
46
|
-
return /^<<<<<<< /m.test(text || '') && /^>>>>>>> /m.test(text || '')
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
// Strip and de-nest conflict markers to prevent exponential 2^N code ballooning
|
|
50
|
-
function stripConflictMarkers(text, prefer = 'local') {
|
|
51
|
-
if (!text || !hasConflictMarkers(text)) return text || ''
|
|
52
|
-
const lines = text.split('\n')
|
|
53
|
-
const out = []
|
|
54
|
-
let inConflict = false
|
|
55
|
-
let inTheirs = false
|
|
56
|
-
const localBlock = []
|
|
57
|
-
const theirsBlock = []
|
|
58
|
-
|
|
59
|
-
for (let i = 0; i < lines.length; i++) {
|
|
60
|
-
const line = lines[i]
|
|
61
|
-
if (/^<<<<<<< /.test(line)) {
|
|
62
|
-
inConflict = true
|
|
63
|
-
inTheirs = false
|
|
64
|
-
localBlock.length = 0
|
|
65
|
-
theirsBlock.length = 0
|
|
66
|
-
continue
|
|
67
|
-
}
|
|
68
|
-
if (/^=======$/.test(line) && inConflict) {
|
|
69
|
-
inTheirs = true
|
|
70
|
-
continue
|
|
71
|
-
}
|
|
72
|
-
if (/^>>>>>>> /.test(line) && inConflict) {
|
|
73
|
-
inConflict = false
|
|
74
|
-
out.push(...(prefer === 'theirs' ? theirsBlock : localBlock))
|
|
75
|
-
continue
|
|
76
|
-
}
|
|
77
|
-
if (inConflict) {
|
|
78
|
-
if (inTheirs) theirsBlock.push(line)
|
|
79
|
-
else localBlock.push(line)
|
|
80
|
-
} else {
|
|
81
|
-
out.push(line)
|
|
82
|
-
}
|
|
83
|
-
}
|
|
84
|
-
return out.join('\n')
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
// Which base line-range an edit replaced, and the replacement lines.
|
|
88
|
-
function changedRange(base, other) {
|
|
89
|
-
let p = 0
|
|
90
|
-
while (p < base.length && p < other.length && base[p] === other[p]) p++
|
|
91
|
-
let s = 0
|
|
92
|
-
while (s < base.length - p && s < other.length - p && base[base.length - 1 - s] === other[other.length - 1 - s]) s++
|
|
93
|
-
return { startBase: p, endBase: base.length - s, newLines: other.slice(p, other.length - s) }
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
// ── A REAL DIFF3 (27 Sep 2026) ─────────────────────────────────────────────────────────────────
|
|
97
|
-
// changedRange() is the span from a side's FIRST changed line to its LAST. That is the right
|
|
98
|
-
// answer to "how much changed" (summarizeChange) and the wrong one for a merge: two one-line edits
|
|
99
|
-
// 500 lines apart are one 500-line change to it, so the other side touching anything in between
|
|
100
|
-
// turned the whole span into a single conflict. Measured on a real cherry-pick of sync.js (a
|
|
101
|
-
// 5-line conflict to git's diff3): one conflict block of 2,140 lines. The same copy is merge3.js's
|
|
102
|
-
// "git-style" floor in icr-merge and extension.js's, and tests/diff3-test.mjs pins all three.
|
|
103
|
-
//
|
|
104
|
-
// lineHunks: Myers' O(ND) diff on what lies between the common prefix and suffix, each separate
|
|
105
|
-
// edit as its own hunk [baseStart, baseEnd, sideStart, sideEnd). Past `maxD` edits it returns null
|
|
106
|
-
// and merge3 keeps the one-range merge, which is coarse but never loses a line.
|
|
107
|
-
function lineHunks(a, b, maxD = 1000) {
|
|
108
|
-
let p = 0
|
|
109
|
-
while (p < a.length && p < b.length && a[p] === b[p]) p++
|
|
110
|
-
let s = 0
|
|
111
|
-
while (s < a.length - p && s < b.length - p && a[a.length - 1 - s] === b[b.length - 1 - s]) s++
|
|
112
|
-
const n = a.length - p - s, m = b.length - p - s
|
|
113
|
-
if (n === 0 && m === 0) return []
|
|
114
|
-
if (n === 0 || m === 0) return [[p, p + n, p, p + m]]
|
|
115
|
-
const max = Math.min(n + m, maxD)
|
|
116
|
-
const off = max + 1
|
|
117
|
-
const v = new Int32Array(2 * max + 3) // v[k + off]: furthest x reached on diagonal k
|
|
118
|
-
const trace = [] // v's window [-d, d] as it stood before step d
|
|
119
|
-
let found = -1
|
|
120
|
-
for (let d = 0; d <= max && found < 0; d++) {
|
|
121
|
-
trace.push(v.slice(off - d, off + d + 1))
|
|
122
|
-
for (let k = -d; k <= d; k += 2) {
|
|
123
|
-
let x = (k === -d || (k !== d && v[off + k - 1] < v[off + k + 1])) ? v[off + k + 1] : v[off + k - 1] + 1
|
|
124
|
-
let y = x - k
|
|
125
|
-
while (x < n && y < m && a[p + x] === b[p + y]) { x++; y++ }
|
|
126
|
-
v[off + k] = x
|
|
127
|
-
if (x >= n && y >= m) { found = d; break }
|
|
128
|
-
}
|
|
129
|
-
}
|
|
130
|
-
if (found < 0) return null
|
|
131
|
-
// Walk back from (n, m): each step d undoes one insertion or deletion, then its snake.
|
|
132
|
-
const steps = [] // [x0, y0, x1, y1], one edit each, collected last-first
|
|
133
|
-
let x = n, y = m
|
|
134
|
-
for (let d = found; d > 0; d--) {
|
|
135
|
-
const w = trace[d], at = (k) => w[k + d]
|
|
136
|
-
const k = x - y
|
|
137
|
-
const down = k === -d || (k !== d && at(k - 1) < at(k + 1)) // true: came by an insertion from b
|
|
138
|
-
const pk = down ? k + 1 : k - 1
|
|
139
|
-
const px = at(pk), py = px - pk
|
|
140
|
-
steps.push(down ? [px, py, px, py + 1] : [px, py, px + 1, py])
|
|
141
|
-
x = px; y = py
|
|
142
|
-
}
|
|
143
|
-
steps.reverse()
|
|
144
|
-
const hunks = []
|
|
145
|
-
for (const [x0, y0, x1, y1] of steps) {
|
|
146
|
-
const last = hunks[hunks.length - 1]
|
|
147
|
-
if (last && last[1] === p + x0 && last[3] === p + y0) { last[1] = p + x1; last[3] = p + y1 }
|
|
148
|
-
else hunks.push([p + x0, p + x1, p + y0, p + y1])
|
|
149
|
-
}
|
|
150
|
-
return hunks
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
// One side's version of base lines [rs, re) with its hunks inside that range applied.
|
|
154
|
-
function sideOfRegion(b, rs, re, hunks, src) {
|
|
155
|
-
const out = []
|
|
156
|
-
let cur = rs
|
|
157
|
-
for (const [bs, be, os, oe] of hunks) {
|
|
158
|
-
for (let i = cur; i < bs; i++) out.push(b[i])
|
|
159
|
-
for (let i = os; i < oe; i++) out.push(src[i])
|
|
160
|
-
cur = be
|
|
161
|
-
}
|
|
162
|
-
for (let i = cur; i < re; i++) out.push(b[i])
|
|
163
|
-
return out
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
// Where else a hunk could sit and mean the same edit: a pure insertion or deletion inside a run of
|
|
167
|
-
// identical lines ("}" "}" "}") can slide along it, and which position the diff chose is an
|
|
168
|
-
// accident of the algorithm. Two sides' edits count as touching if they COULD touch, so a merge
|
|
169
|
-
// that hinges on that accident conflicts instead of guessing (git reaches the same answer by
|
|
170
|
-
// sliding every hunk to one canonical end first). Returns [lo, hi] in base lines.
|
|
171
|
-
function slideSpan(b, [bs, be, os, oe], src) {
|
|
172
|
-
let lo = bs, hi = be
|
|
173
|
-
if (os === oe && be > bs) { // pure deletion of b[bs, be)
|
|
174
|
-
let s = bs, e = be
|
|
175
|
-
while (s > 0 && b[s - 1] === b[e - 1]) { s--; e-- }
|
|
176
|
-
lo = s
|
|
177
|
-
s = bs; e = be
|
|
178
|
-
while (e < b.length && b[s] === b[e]) { s++; e++ }
|
|
179
|
-
hi = e
|
|
180
|
-
} else if (bs === be && oe > os) { // pure insertion at bs of src[os, oe)
|
|
181
|
-
const n = oe - os
|
|
182
|
-
let k = bs, r = 0 // r: how far the inserted block has rotated
|
|
183
|
-
while (k > 0 && b[k - 1] === src[os + ((n - 1 - r) % n + n) % n]) { k--; r++ }
|
|
184
|
-
lo = k
|
|
185
|
-
k = bs; r = 0
|
|
186
|
-
while (k < b.length && b[k] === src[os + (r % n)]) { k++; r++ }
|
|
187
|
-
hi = k
|
|
188
|
-
}
|
|
189
|
-
return [lo, hi]
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
// diff3 over line arrays: hunks from the two sides that overlap OR TOUCH form one region (touching
|
|
193
|
-
// changes conflict, as in git: two edits with no unchanged line between them are one decision). A
|
|
194
|
-
// region only one side changed takes that side; both sides making the same change takes it once;
|
|
195
|
-
// anything else is a conflict of THAT region only. Null when either diff is past the work bound.
|
|
196
|
-
function diff3Lines(b, mine, theirs) {
|
|
197
|
-
const hm = lineHunks(b, mine), ht = lineHunks(b, theirs)
|
|
198
|
-
if (!hm || !ht) return null
|
|
199
|
-
const sm = hm.map((h) => slideSpan(b, h, mine)), st = ht.map((h) => slideSpan(b, h, theirs))
|
|
200
|
-
const out = []
|
|
201
|
-
let conflict = false, pos = 0, i = 0, j = 0
|
|
202
|
-
while (i < hm.length || j < ht.length) {
|
|
203
|
-
const fromMine = j >= ht.length || (i < hm.length && hm[i][0] <= ht[j][0])
|
|
204
|
-
const first = fromMine ? hm[i] : ht[j]
|
|
205
|
-
let reach = fromMine ? sm[i++][1] : st[j++][1] // how far this region's edits could extend
|
|
206
|
-
const rm = fromMine ? [first] : [], rt = fromMine ? [] : [first]
|
|
207
|
-
const rs = first[0]
|
|
208
|
-
let re = first[1]
|
|
209
|
-
for (;;) {
|
|
210
|
-
if (i < hm.length && sm[i][0] <= reach) { re = Math.max(re, hm[i][1]); reach = Math.max(reach, sm[i][1]); rm.push(hm[i++]); continue }
|
|
211
|
-
if (j < ht.length && st[j][0] <= reach) { re = Math.max(re, ht[j][1]); reach = Math.max(reach, st[j][1]); rt.push(ht[j++]); continue }
|
|
212
|
-
break
|
|
213
|
-
}
|
|
214
|
-
for (let x = pos; x < rs; x++) out.push(b[x])
|
|
215
|
-
if (!rt.length) out.push(...sideOfRegion(b, rs, re, rm, mine))
|
|
216
|
-
else if (!rm.length) out.push(...sideOfRegion(b, rs, re, rt, theirs))
|
|
217
|
-
else {
|
|
218
|
-
const vm = sideOfRegion(b, rs, re, rm, mine), vt = sideOfRegion(b, rs, re, rt, theirs)
|
|
219
|
-
if (vm.length === vt.length && vm.every((l, k) => l === vt[k])) out.push(...vm)
|
|
220
|
-
else { conflict = true; out.push('<<<<<<< local (yours)', ...vm, '=======', ...vt, '>>>>>>> incoming (theirs)') }
|
|
221
|
-
}
|
|
222
|
-
pos = re
|
|
223
|
-
}
|
|
224
|
-
for (let x = pos; x < b.length; x++) out.push(b[x])
|
|
225
|
-
return { lines: out, conflict }
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
// Best-effort "what was defined here" across common languages — for the board.
|
|
229
|
-
function symbolsIn(text) {
|
|
230
|
-
const names = new Set()
|
|
231
|
-
const add = (re) => { let m; while ((m = re.exec(text)) && names.size < 8) names.add(m[1]) }
|
|
232
|
-
add(/\bfunction\s+(\w+)/g)
|
|
233
|
-
add(/\bclass\s+(\w+)/g)
|
|
234
|
-
add(/\bdef\s+(\w+)/g)
|
|
235
|
-
add(/\b(?:export\s+)?(?:const|let|var)\s+(\w+)\s*=/g)
|
|
236
|
-
return [...names].slice(0, 8)
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
// Classify a change as a small PATCH vs wholesale REWRITE.
|
|
240
|
-
function summarizeChange(base, next) {
|
|
241
|
-
const b = (base || '').split('\n')
|
|
242
|
-
const n = (next || '').split('\n')
|
|
243
|
-
if (!base) return { isRewrite: false, changedLines: n.length, totalLines: n.length, symbols: symbolsIn(next || '') }
|
|
244
|
-
const r = changedRange(b, n)
|
|
245
|
-
const churn = Math.max(r.endBase - r.startBase, r.newLines.length)
|
|
246
|
-
const total = Math.max(b.length, n.length)
|
|
247
|
-
const isRewrite = total > 0 && churn >= 4 && churn / total >= 0.5
|
|
248
|
-
return { isRewrite, changedLines: churn, totalLines: total, symbols: symbolsIn(r.newLines.join('\n')) }
|
|
249
|
-
}
|
|
250
|
-
|
|
251
|
-
// 3-way merge for the sync layer (disk <-> shared doc)
|
|
252
|
-
function merge3(base, mine, theirs) {
|
|
253
|
-
const norm = (s) => (s || '').replace(/\r\n/g, '\n')
|
|
254
|
-
const nm0 = norm(mine), nt0 = norm(theirs), nb0 = norm(base)
|
|
255
|
-
if (nm0 === nt0) return { text: nt0, conflict: false }
|
|
256
|
-
if (nb0 === nt0) return { text: nm0, conflict: false }
|
|
257
|
-
if (nb0 === nm0) return { text: nt0, conflict: false }
|
|
258
|
-
|
|
259
|
-
const cleanBase = hasConflictMarkers(base) ? stripConflictMarkers(base, 'local') : (base || '')
|
|
260
|
-
const cleanMine = hasConflictMarkers(mine) ? stripConflictMarkers(mine, 'local') : (mine || '')
|
|
261
|
-
const cleanTheirs = hasConflictMarkers(theirs) ? stripConflictMarkers(theirs, 'theirs') : (theirs || '')
|
|
262
|
-
|
|
263
|
-
const nm = norm(cleanMine), nt = norm(cleanTheirs), nb = norm(cleanBase)
|
|
264
|
-
if (nm === nt) return { text: nt, conflict: false }
|
|
265
|
-
if (nb === nt) return { text: nm, conflict: false }
|
|
266
|
-
if (nb === nm) return { text: nt, conflict: false }
|
|
267
|
-
|
|
268
|
-
const b = nb.split('\n')
|
|
269
|
-
const d3 = diff3Lines(b, nm.split('\n'), nt.split('\n'))
|
|
270
|
-
if (d3) return { text: d3.lines.join('\n'), conflict: d3.conflict }
|
|
271
|
-
// Past diff3's work bound (two sides that share almost nothing): one range per side.
|
|
272
|
-
const mr = changedRange(b, nm.split('\n'))
|
|
273
|
-
const tr = changedRange(b, nt.split('\n'))
|
|
274
|
-
if (mr.endBase <= tr.startBase || tr.endBase <= mr.startBase) {
|
|
275
|
-
const out = b.slice()
|
|
276
|
-
for (const e of [mr, tr].sort((x, y) => y.startBase - x.startBase)) {
|
|
277
|
-
out.splice(e.startBase, e.endBase - e.startBase, ...e.newLines)
|
|
278
|
-
}
|
|
279
|
-
return { text: out.join('\n'), conflict: false }
|
|
280
|
-
}
|
|
281
|
-
const start = Math.min(mr.startBase, tr.startBase)
|
|
282
|
-
const end = Math.max(mr.endBase, tr.endBase)
|
|
283
|
-
const mineBlock = [...b.slice(start, mr.startBase), ...mr.newLines, ...b.slice(mr.endBase, end)]
|
|
284
|
-
const theirsBlock = [...b.slice(start, tr.startBase), ...tr.newLines, ...b.slice(tr.endBase, end)]
|
|
285
|
-
if (mineBlock.length === theirsBlock.length && mineBlock.every((l, k) => l === theirsBlock[k])) {
|
|
286
|
-
const out = [...b.slice(0, start), ...theirsBlock, ...b.slice(end)]
|
|
287
|
-
return { text: out.join('\n'), conflict: false }
|
|
288
|
-
}
|
|
289
|
-
const out = [
|
|
290
|
-
...b.slice(0, start),
|
|
291
|
-
'<<<<<<< local (yours)',
|
|
292
|
-
...mineBlock,
|
|
293
|
-
'=======',
|
|
294
|
-
...theirsBlock,
|
|
295
|
-
'>>>>>>> incoming (theirs)',
|
|
296
|
-
...b.slice(end),
|
|
297
|
-
]
|
|
298
|
-
return { text: out.join('\n'), conflict: true }
|
|
299
|
-
}
|
|
300
|
-
|
|
301
|
-
function describeConflicts(cs) {
|
|
302
|
-
return (cs || []).map((c) =>
|
|
303
|
-
c.startsWith('ref:') ? `'${c.slice(4)}' was removed or renamed but is still used`
|
|
304
|
-
: c.startsWith('fn:') ? `both sides changed function ${c.slice(3)}`
|
|
305
|
-
: c.startsWith('class:') ? `both sides changed class ${c.slice(6)}`
|
|
306
|
-
: `both sides changed ${c.replace(/^\w+:/, '')}`).join('; ')
|
|
307
|
-
}
|
|
308
|
-
|
|
309
|
-
function icrMerge3(base, mine, theirs, relPath, icrlib) {
|
|
310
|
-
const lm = merge3(base, mine, theirs)
|
|
311
|
-
if (!icrlib || !icrlib.supports || !icrlib.supports(relPath)) return lm
|
|
312
|
-
let r
|
|
313
|
-
try { r = icrlib.structuralMerge(base, mine, theirs, { filename: relPath }) } catch (e) { return lm }
|
|
314
|
-
if (r.status === 'auto') {
|
|
315
|
-
const renamed = r.renames && r.renames.length
|
|
316
|
-
return { text: r.text, conflict: false, icr: renamed ? 'rename' : 'structural', icrWarning: renamed ? `auto-applied rename ${r.renames.join(', ')}` : undefined }
|
|
317
|
-
}
|
|
318
|
-
if (r.status === 'semantic-conflict') return Object.assign({}, lm, { icrWarning: describeConflicts(r.conflicts) })
|
|
319
|
-
return lm
|
|
320
|
-
}
|
|
321
|
-
|
|
322
|
-
// createReconciler: binds to a session's maps, state, and callbacks, returning
|
|
323
|
-
// a reconcile(relPath, origin = 'local') function.
|
|
324
|
-
function createReconciler(ctx) {
|
|
325
|
-
const {
|
|
326
|
-
root,
|
|
327
|
-
name = 'anon',
|
|
328
|
-
SKIP = new Set(['HIVE_BOARD.md', 'HIVE_CHAT.md', 'HIVE_TASKS.md', 'HIVE_RULES.md', 'HIVE_MEMBERS.md', 'HIVE_FOR_AGENTS.md', '.hive.json']),
|
|
329
|
-
isIgnored = () => false,
|
|
330
|
-
canOpen = () => true,
|
|
331
|
-
canWrite = () => true,
|
|
332
|
-
readText = (full) => {
|
|
333
|
-
try {
|
|
334
|
-
const buf = fs.readFileSync(full)
|
|
335
|
-
if (buf.length > 1_000_000 || buf.includes(0)) return null
|
|
336
|
-
return buf.toString('utf8')
|
|
337
|
-
} catch { return null }
|
|
338
|
-
},
|
|
339
|
-
writeToDisk,
|
|
340
|
-
openFile,
|
|
341
|
-
touchFile,
|
|
342
|
-
fileDocs,
|
|
343
|
-
manifest,
|
|
344
|
-
bases,
|
|
345
|
-
forkBases,
|
|
346
|
-
known,
|
|
347
|
-
mtimes,
|
|
348
|
-
conflicted = new Set(),
|
|
349
|
-
icrWarned = new Map(),
|
|
350
|
-
snappedBase = new Set(),
|
|
351
|
-
preexisting = new Set(),
|
|
352
|
-
priorShadow,
|
|
353
|
-
shadow,
|
|
354
|
-
say = () => {},
|
|
355
|
-
log = () => {},
|
|
356
|
-
markEditing = () => {},
|
|
357
|
-
noteCoEditing = () => {},
|
|
358
|
-
noteIfRewrite = () => {},
|
|
359
|
-
captureSnapshot = () => {},
|
|
360
|
-
lastLocalEdit,
|
|
361
|
-
// Fork-gate & provenance
|
|
362
|
-
FORK_GATE = false,
|
|
363
|
-
captureLocal,
|
|
364
|
-
forkRecord,
|
|
365
|
-
forkBlock,
|
|
366
|
-
detectFork,
|
|
367
|
-
renderConflict,
|
|
368
|
-
adoptResolved,
|
|
369
|
-
stampProvenance,
|
|
370
|
-
attestHead,
|
|
371
|
-
fusionReason,
|
|
372
|
-
supports,
|
|
373
|
-
languageFor,
|
|
374
|
-
icrlib
|
|
375
|
-
} = ctx
|
|
376
|
-
|
|
377
|
-
return function reconcile(relPath, origin = 'local') {
|
|
378
|
-
if (SKIP.has(relPath) || isIgnored(relPath)) return
|
|
379
|
-
if (!canOpen(relPath)) return
|
|
380
|
-
const full = path.join(root, relPath)
|
|
381
|
-
const exists = fs.existsSync(full)
|
|
382
|
-
const disk = exists ? readText(full) : null
|
|
383
|
-
if (exists && disk === null) return
|
|
384
|
-
|
|
385
|
-
if (origin === 'local' && !canWrite(relPath)) {
|
|
386
|
-
const fe2 = fileDocs.get(relPath)
|
|
387
|
-
if (fe2 && fe2.synced) {
|
|
388
|
-
const dt = fe2.text.toString()
|
|
389
|
-
if (dt && dt !== disk) writeToDisk(relPath, dt)
|
|
390
|
-
}
|
|
391
|
-
return
|
|
392
|
-
}
|
|
393
|
-
|
|
394
|
-
let fe = fileDocs.get(relPath)
|
|
395
|
-
if (!fe) { if (disk === null) return; openFile(relPath); return }
|
|
396
|
-
if (!fe.synced) return
|
|
397
|
-
if (touchFile) touchFile(relPath)
|
|
398
|
-
|
|
399
|
-
if (FORK_GATE && captureLocal) captureLocal(relPath)
|
|
400
|
-
|
|
401
|
-
if (FORK_GATE && fe.conflict && forkRecord) {
|
|
402
|
-
const rec = forkRecord(fe)
|
|
403
|
-
if (rec) {
|
|
404
|
-
const block = forkBlock(fe, rec)
|
|
405
|
-
if (origin === 'local') {
|
|
406
|
-
if (disk === block) return
|
|
407
|
-
const fork0 = forkBases.get(relPath)
|
|
408
|
-
const sawMarkers = fork0 === block || hasConflictMarkers(fork0 || '')
|
|
409
|
-
if (sawMarkers && disk !== null && !hasConflictMarkers(disk)) {
|
|
410
|
-
fe.doc.transact(() => {
|
|
411
|
-
applyDiff(fe.text, disk)
|
|
412
|
-
for (const v of rec.versions) fe.forkmark.set(v.hash, 1)
|
|
413
|
-
for (const key of [...fe.conflict.keys()]) fe.conflict.delete(key)
|
|
414
|
-
})
|
|
415
|
-
if (stampProvenance) stampProvenance(relPath, block || '', disk)
|
|
416
|
-
conflicted.delete(relPath)
|
|
417
|
-
known.add(relPath); bases.set(relPath, disk); forkBases.set(relPath, disk)
|
|
418
|
-
try { say(`✓ conflict in ${relPath} resolved by ${name}.`) } catch { }
|
|
419
|
-
return
|
|
420
|
-
}
|
|
421
|
-
if (!sawMarkers && disk !== null && disk !== fork0) {
|
|
422
|
-
if (detectFork) detectFork(relPath)
|
|
423
|
-
return
|
|
424
|
-
}
|
|
425
|
-
if (renderConflict) renderConflict(relPath)
|
|
426
|
-
return
|
|
427
|
-
}
|
|
428
|
-
if (renderConflict) renderConflict(relPath)
|
|
429
|
-
return
|
|
430
|
-
}
|
|
431
|
-
if (disk !== null && hasConflictMarkers(disk) && !hasConflictMarkers(fe.text.toString())) {
|
|
432
|
-
if (adoptResolved) { adoptResolved(relPath); return }
|
|
433
|
-
}
|
|
434
|
-
}
|
|
435
|
-
|
|
436
|
-
const docText = fe.text.toString()
|
|
437
|
-
const noHistory = !bases.has(relPath) && !(priorShadow && priorShadow.has(relPath))
|
|
438
|
-
if (docText === '' && (!manifest.has(relPath) || (disk && noHistory))) {
|
|
439
|
-
if (disk === null) return
|
|
440
|
-
fe.doc.transact(() => applyDiff(fe.text, disk))
|
|
441
|
-
if (stampProvenance) stampProvenance(relPath, '', disk)
|
|
442
|
-
manifest.set(relPath, 1)
|
|
443
|
-
known.add(relPath); bases.set(relPath, disk); forkBases.set(relPath, disk)
|
|
444
|
-
if (shadow) shadow.set(relPath, disk)
|
|
445
|
-
try { mtimes.set(relPath, fs.statSync(full).mtimeMs) } catch { }
|
|
446
|
-
captureSnapshot(relPath, disk, name, { force: true, kind: 'base', label: 'created' })
|
|
447
|
-
snappedBase.add(relPath)
|
|
448
|
-
return
|
|
449
|
-
}
|
|
450
|
-
|
|
451
|
-
const yt = fe.text
|
|
452
|
-
if (disk === null) {
|
|
453
|
-
writeToDisk(relPath, docText)
|
|
454
|
-
bases.set(relPath, docText); forkBases.set(relPath, docText)
|
|
455
|
-
return
|
|
456
|
-
}
|
|
457
|
-
|
|
458
|
-
if (disk === docText) {
|
|
459
|
-
if (origin === 'local' && !known.has(relPath) && disk) markEditing(relPath)
|
|
460
|
-
known.add(relPath); bases.set(relPath, disk)
|
|
461
|
-
if (shadow) shadow.set(relPath, disk)
|
|
462
|
-
if (!forkBases.has(relPath) || origin === 'local') forkBases.set(relPath, disk)
|
|
463
|
-
if (!snappedBase.has(relPath) && disk) {
|
|
464
|
-
captureSnapshot(relPath, disk, name, { force: true, kind: 'base', label: 'baseline' })
|
|
465
|
-
snappedBase.add(relPath)
|
|
466
|
-
}
|
|
467
|
-
return
|
|
468
|
-
}
|
|
469
|
-
|
|
470
|
-
// FIRST-CONTACT ADOPT
|
|
471
|
-
if (!bases.has(relPath) && docText !== '' && disk !== docText) {
|
|
472
|
-
if (!preexisting.has(relPath)) {
|
|
473
|
-
const nl = (s) => (s === '' || s.endsWith('\n') ? s : s + '\n')
|
|
474
|
-
const r0 = icrMerge3('', nl(disk), nl(docText), relPath, icrlib)
|
|
475
|
-
if (r0.text !== docText) fe.doc.transact(() => applyDiff(yt, r0.text))
|
|
476
|
-
if (r0.text !== disk) writeToDisk(relPath, r0.text)
|
|
477
|
-
if (r0.conflict) conflicted.add(relPath)
|
|
478
|
-
bases.set(relPath, r0.text); forkBases.set(relPath, r0.text)
|
|
479
|
-
if (shadow) shadow.set(relPath, r0.text)
|
|
480
|
-
if (!manifest.has(relPath)) manifest.set(relPath, 1)
|
|
481
|
-
try { say(`⚠ ${relPath} was created by two participants at once — both versions were merged${r0.conflict ? ', and CONFLICT markers need resolving' : ''}.`) } catch { }
|
|
482
|
-
return
|
|
483
|
-
}
|
|
484
|
-
const anc = priorShadow ? priorShadow.get(relPath) : undefined
|
|
485
|
-
if (anc !== undefined && disk !== null) {
|
|
486
|
-
if (disk === anc) {
|
|
487
|
-
writeToDisk(relPath, docText)
|
|
488
|
-
bases.set(relPath, docText); forkBases.set(relPath, docText)
|
|
489
|
-
if (!snappedBase.has(relPath)) {
|
|
490
|
-
captureSnapshot(relPath, docText, name, { force: true, kind: 'base', label: 'room copy adopted on rejoin (local was unchanged)' })
|
|
491
|
-
snappedBase.add(relPath)
|
|
492
|
-
}
|
|
493
|
-
return
|
|
494
|
-
}
|
|
495
|
-
captureSnapshot(relPath, disk, name, { force: true, kind: 'manual', label: 'local copy before rejoin merge' })
|
|
496
|
-
const rj = icrMerge3(anc, disk, docText, relPath, icrlib)
|
|
497
|
-
if (rj.text !== docText) fe.doc.transact(() => applyDiff(yt, rj.text))
|
|
498
|
-
if (rj.text !== disk) writeToDisk(relPath, rj.text)
|
|
499
|
-
if (rj.conflict) conflicted.add(relPath)
|
|
500
|
-
bases.set(relPath, rj.text); forkBases.set(relPath, rj.text)
|
|
501
|
-
if (shadow) shadow.set(relPath, rj.text)
|
|
502
|
-
if (!manifest.has(relPath)) manifest.set(relPath, 1)
|
|
503
|
-
try { say(`⚠ ${relPath} changed locally while ${name} was disconnected — the local edit and the room copy were merged${rj.conflict ? ', and CONFLICT markers need resolving' : ''}. Pre-merge local copy is in the file's history.`) } catch { }
|
|
504
|
-
return
|
|
505
|
-
}
|
|
506
|
-
captureSnapshot(relPath, disk, name, { force: true, kind: 'manual', label: 'local copy at join (room copy adopted)' })
|
|
507
|
-
writeToDisk(relPath, docText)
|
|
508
|
-
bases.set(relPath, docText); forkBases.set(relPath, docText)
|
|
509
|
-
if (!snappedBase.has(relPath)) {
|
|
510
|
-
captureSnapshot(relPath, docText, name, { force: true, kind: 'base', label: 'adopted from room at join' })
|
|
511
|
-
snappedBase.add(relPath)
|
|
512
|
-
}
|
|
513
|
-
try { say(`↓ ${relPath} already existed in this room — ${name} adopted the room's copy. The local version before joining is saved in the file's history.`) } catch { }
|
|
514
|
-
return
|
|
515
|
-
}
|
|
516
|
-
|
|
517
|
-
const base = bases.has(relPath) ? bases.get(relPath) : disk
|
|
518
|
-
const fork = forkBases.has(relPath) ? forkBases.get(relPath) : base
|
|
519
|
-
if (origin === 'local') { noteCoEditing(relPath); markEditing(relPath) }
|
|
520
|
-
|
|
521
|
-
let res, reAdded = false
|
|
522
|
-
if (origin === 'local' && hasConflictMarkers(docText) && !hasConflictMarkers(disk)) {
|
|
523
|
-
res = { text: disk, conflict: false }
|
|
524
|
-
} else if (origin === 'local') {
|
|
525
|
-
noteIfRewrite(relPath, fork, disk)
|
|
526
|
-
const sc = summarizeChange(fork, disk)
|
|
527
|
-
const isCleanRewrite = sc.isRewrite && supports && supports(relPath) && languageFor && languageFor(relPath).parses(disk)
|
|
528
|
-
const theirsNew = changedRange(fork.split('\n'), docText.split('\n')).newLines.filter((l) => l.length)
|
|
529
|
-
const mineLines = new Set(disk.split('\n'))
|
|
530
|
-
const integrated = theirsNew.every((l) => mineLines.has(l))
|
|
531
|
-
if (integrated || (isCleanRewrite && !hasConflictMarkers(docText))) {
|
|
532
|
-
res = { text: disk, conflict: false }
|
|
533
|
-
} else {
|
|
534
|
-
res = icrMerge3(fork, disk, docText, relPath, icrlib)
|
|
535
|
-
reAdded = !res.conflict && res.text !== disk
|
|
536
|
-
}
|
|
537
|
-
} else {
|
|
538
|
-
res = icrMerge3(base, disk, docText, relPath, icrlib)
|
|
539
|
-
}
|
|
540
|
-
|
|
541
|
-
if (res && !res.conflict && fusionReason) {
|
|
542
|
-
const why = fusionReason(res.text, disk, docText, (supports && supports(relPath)) ? languageFor(relPath) : null)
|
|
543
|
-
if (why) {
|
|
544
|
-
conflicted.add(relPath)
|
|
545
|
-
if (icrWarned.get(relPath) !== why) {
|
|
546
|
-
icrWarned.set(relPath, why)
|
|
547
|
-
log(`[${name}] !! REFUSED a fused merge of ${relPath}: ${why}`)
|
|
548
|
-
try {
|
|
549
|
-
say(`[!] @all ${relPath}: a merge was REFUSED because it fused rather than merged — ${why}. Nothing was written: the room's copy and ${name}'s local copy are both intact and unchanged. Someone must reconcile them deliberately; a plain file write will merge against the other version again, hive_edit replaces it outright.`)
|
|
550
|
-
} catch { }
|
|
551
|
-
}
|
|
552
|
-
return
|
|
553
|
-
}
|
|
554
|
-
}
|
|
555
|
-
|
|
556
|
-
if (origin === 'local' && res.text !== docText) {
|
|
557
|
-
const sc = summarizeChange(docText, res.text)
|
|
558
|
-
captureSnapshot(relPath, docText, name, { force: sc.isRewrite, churn: `${sc.changedLines}/${sc.totalLines} lines` })
|
|
559
|
-
if (typeof lastLocalEdit === 'function') lastLocalEdit(relPath)
|
|
560
|
-
}
|
|
561
|
-
fe.doc.transact(() => applyDiff(yt, res.text))
|
|
562
|
-
if (origin === 'local') {
|
|
563
|
-
if (FORK_GATE) { if (res.text !== docText && attestHead) attestHead(relPath, res.text, docText) }
|
|
564
|
-
else if (res.text !== docText && stampProvenance) stampProvenance(relPath, docText, res.text)
|
|
565
|
-
}
|
|
566
|
-
if (res.text !== disk) writeToDisk(relPath, res.text)
|
|
567
|
-
known.add(relPath); bases.set(relPath, res.text)
|
|
568
|
-
if (origin === 'local') forkBases.set(relPath, res.text)
|
|
569
|
-
else if (!forkBases.get(relPath)) forkBases.set(relPath, res.text)
|
|
570
|
-
|
|
571
|
-
const hasMarkers = res.conflict || hasConflictMarkers(res.text)
|
|
572
|
-
if (hasMarkers && !conflicted.has(relPath)) {
|
|
573
|
-
conflicted.add(relPath)
|
|
574
|
-
log(`[${name}] ⚠ merge conflict in ${relPath} — kept BOTH versions with <<<<<<< markers`)
|
|
575
|
-
try { say(`⚠ MERGE CONFLICT in ${relPath} — it has <<<<<<< markers. Whoever owns it: resolve before continuing.`) } catch { }
|
|
576
|
-
} else if (!hasMarkers && conflicted.has(relPath)) {
|
|
577
|
-
conflicted.delete(relPath)
|
|
578
|
-
try { say(`✓ conflict in ${relPath} resolved.`) } catch { }
|
|
579
|
-
} else if (reAdded) {
|
|
580
|
-
log(`[${name}] protected ${relPath}: your edit was based on an older version — re-added changes that arrived since (nobody's work lost)`)
|
|
581
|
-
try { say(`↺ ${relPath}: ${name}'s edit was based on an older copy — kept the changes that landed in between (nothing lost).`) } catch { }
|
|
582
|
-
} else if (!hasMarkers && res.icr === 'rename') {
|
|
583
|
-
log(`[${name}] ICR merged ${relPath} (detected a rename and updated the call sites)`)
|
|
584
|
-
} else if (!hasMarkers && res.icr === 'structural') {
|
|
585
|
-
log(`[${name}] ICR merged ${relPath} structurally (both edits kept, syntax guaranteed)`)
|
|
586
|
-
} else if (!hasMarkers) {
|
|
587
|
-
log(`[${name}] merged ${relPath} (both edits kept)`)
|
|
588
|
-
}
|
|
589
|
-
|
|
590
|
-
if (res.icrWarning && icrWarned.get(relPath) !== res.icrWarning) {
|
|
591
|
-
icrWarned.set(relPath, res.icrWarning)
|
|
592
|
-
log(`[${name}] ⚠ ICR flag in ${relPath} — ${res.icrWarning}`)
|
|
593
|
-
try { say(`⚠ ICR flag in ${relPath}: ${res.icrWarning}. It merged, but review — this is a break a plain merge can't see.`) } catch { }
|
|
594
|
-
} else if (!res.icrWarning && icrWarned.has(relPath)) {
|
|
595
|
-
icrWarned.delete(relPath)
|
|
596
|
-
}
|
|
597
|
-
|
|
598
|
-
if (detectFork) detectFork(relPath)
|
|
599
|
-
}
|
|
600
|
-
}
|
|
601
|
-
|
|
602
|
-
// reconcile direct call
|
|
603
|
-
function reconcile(ctx, relPath, origin = 'local') {
|
|
604
|
-
return createReconciler(ctx)(relPath, origin)
|
|
605
|
-
}
|
|
606
|
-
|
|
607
|
-
module.exports = {
|
|
608
|
-
reconcile,
|
|
609
|
-
createReconciler,
|
|
610
|
-
applyDiff,
|
|
611
|
-
hasConflictMarkers,
|
|
612
|
-
stripConflictMarkers,
|
|
613
|
-
changedRange,
|
|
614
|
-
symbolsIn,
|
|
615
|
-
summarizeChange,
|
|
616
|
-
merge3,
|
|
617
|
-
lineHunks,
|
|
618
|
-
diff3Lines,
|
|
619
|
-
describeConflicts,
|
|
620
|
-
icrMerge3
|
|
621
|
-
}
|
|
1
|
+
// reconcile.cjs — Single source of truth for the 3-way reconcile & merge engine.
|
|
2
|
+
//
|
|
3
|
+
// CommonJS on purpose: sync.js is ESM and the VS Code extension is CJS.
|
|
4
|
+
// A .cjs file is the one shape both can load synchronously without duplication.
|
|
5
|
+
//
|
|
6
|
+
// Acceptance criteria (HIV-8), re-checked 21 Sep 2026 -- FOUR OF FIVE MET:
|
|
7
|
+
// 1. MET one module exports reconcile() and its dependencies (11 exports).
|
|
8
|
+
// 2. MET core.js re-exports from here and sync.js imports core.js. One copy.
|
|
9
|
+
// 3. NOT MET extension/extension.js does NOT require this file. It requires
|
|
10
|
+
// ./icr.cjs (the ICR bundle) and keeps its OWN reconcile() at
|
|
11
|
+
// extension/extension.js:1166. The second copy is still there.
|
|
12
|
+
// 4. MET node tests/test.js -- 95 passed, 0 failed.
|
|
13
|
+
// 5. MET node tests/conflict-test.mjs -- ALL 4 PASS.
|
|
14
|
+
//
|
|
15
|
+
// Criterion 3 read "No second copy anywhere" while a second copy existed, in a file
|
|
16
|
+
// that ships in package.json files[]. A comment asserting an invariant it does not
|
|
17
|
+
// hold is worse than no comment: the next reader trusts it and stops looking. Corrected
|
|
18
|
+
// to what is true rather than deleted, because the gap is the useful part.
|
|
19
|
+
//
|
|
20
|
+
// WHAT ACTUALLY GUARDS THE DRIFT, since it is not this file:
|
|
21
|
+
// tests/extension-parity-test.mjs the ICR bundle vs the engine, 24/24
|
|
22
|
+
// tests/extension-reconcile-characterisation-test.mjs pins the extension's own copy
|
|
23
|
+
// Those two measure that the copies AGREE. They do not make it one copy, and closing
|
|
24
|
+
// criterion 3 for real means the extension loading this module.
|
|
25
|
+
|
|
26
|
+
const fs = require('fs')
|
|
27
|
+
const path = require('path')
|
|
28
|
+
|
|
29
|
+
// Minimal text diff: replace only the changed middle, preserving the common
|
|
30
|
+
// prefix/suffix. Applied to a Y.Text so edits stay small (good for CRDT churn).
|
|
31
|
+
function applyDiff(ytext, next) {
|
|
32
|
+
const cur = ytext.toString()
|
|
33
|
+
if (cur === next) return false
|
|
34
|
+
let p = 0
|
|
35
|
+
while (p < cur.length && p < next.length && cur[p] === next[p]) p++
|
|
36
|
+
let s = 0
|
|
37
|
+
while (s < cur.length - p && s < next.length - p && cur[cur.length - 1 - s] === next[next.length - 1 - s]) s++
|
|
38
|
+
if (cur.length - p - s > 0) ytext.delete(p, cur.length - p - s)
|
|
39
|
+
const ins = next.slice(p, next.length - s)
|
|
40
|
+
if (ins) ytext.insert(p, ins)
|
|
41
|
+
return true
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Detect REAL git-style conflict markers in a file. Line-anchored on purpose.
|
|
45
|
+
function hasConflictMarkers(text) {
|
|
46
|
+
return /^<<<<<<< /m.test(text || '') && /^>>>>>>> /m.test(text || '')
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Strip and de-nest conflict markers to prevent exponential 2^N code ballooning
|
|
50
|
+
function stripConflictMarkers(text, prefer = 'local') {
|
|
51
|
+
if (!text || !hasConflictMarkers(text)) return text || ''
|
|
52
|
+
const lines = text.split('\n')
|
|
53
|
+
const out = []
|
|
54
|
+
let inConflict = false
|
|
55
|
+
let inTheirs = false
|
|
56
|
+
const localBlock = []
|
|
57
|
+
const theirsBlock = []
|
|
58
|
+
|
|
59
|
+
for (let i = 0; i < lines.length; i++) {
|
|
60
|
+
const line = lines[i]
|
|
61
|
+
if (/^<<<<<<< /.test(line)) {
|
|
62
|
+
inConflict = true
|
|
63
|
+
inTheirs = false
|
|
64
|
+
localBlock.length = 0
|
|
65
|
+
theirsBlock.length = 0
|
|
66
|
+
continue
|
|
67
|
+
}
|
|
68
|
+
if (/^=======$/.test(line) && inConflict) {
|
|
69
|
+
inTheirs = true
|
|
70
|
+
continue
|
|
71
|
+
}
|
|
72
|
+
if (/^>>>>>>> /.test(line) && inConflict) {
|
|
73
|
+
inConflict = false
|
|
74
|
+
out.push(...(prefer === 'theirs' ? theirsBlock : localBlock))
|
|
75
|
+
continue
|
|
76
|
+
}
|
|
77
|
+
if (inConflict) {
|
|
78
|
+
if (inTheirs) theirsBlock.push(line)
|
|
79
|
+
else localBlock.push(line)
|
|
80
|
+
} else {
|
|
81
|
+
out.push(line)
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return out.join('\n')
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Which base line-range an edit replaced, and the replacement lines.
|
|
88
|
+
function changedRange(base, other) {
|
|
89
|
+
let p = 0
|
|
90
|
+
while (p < base.length && p < other.length && base[p] === other[p]) p++
|
|
91
|
+
let s = 0
|
|
92
|
+
while (s < base.length - p && s < other.length - p && base[base.length - 1 - s] === other[other.length - 1 - s]) s++
|
|
93
|
+
return { startBase: p, endBase: base.length - s, newLines: other.slice(p, other.length - s) }
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// ── A REAL DIFF3 (27 Sep 2026) ─────────────────────────────────────────────────────────────────
|
|
97
|
+
// changedRange() is the span from a side's FIRST changed line to its LAST. That is the right
|
|
98
|
+
// answer to "how much changed" (summarizeChange) and the wrong one for a merge: two one-line edits
|
|
99
|
+
// 500 lines apart are one 500-line change to it, so the other side touching anything in between
|
|
100
|
+
// turned the whole span into a single conflict. Measured on a real cherry-pick of sync.js (a
|
|
101
|
+
// 5-line conflict to git's diff3): one conflict block of 2,140 lines. The same copy is merge3.js's
|
|
102
|
+
// "git-style" floor in icr-merge and extension.js's, and tests/diff3-test.mjs pins all three.
|
|
103
|
+
//
|
|
104
|
+
// lineHunks: Myers' O(ND) diff on what lies between the common prefix and suffix, each separate
|
|
105
|
+
// edit as its own hunk [baseStart, baseEnd, sideStart, sideEnd). Past `maxD` edits it returns null
|
|
106
|
+
// and merge3 keeps the one-range merge, which is coarse but never loses a line.
|
|
107
|
+
function lineHunks(a, b, maxD = 1000) {
|
|
108
|
+
let p = 0
|
|
109
|
+
while (p < a.length && p < b.length && a[p] === b[p]) p++
|
|
110
|
+
let s = 0
|
|
111
|
+
while (s < a.length - p && s < b.length - p && a[a.length - 1 - s] === b[b.length - 1 - s]) s++
|
|
112
|
+
const n = a.length - p - s, m = b.length - p - s
|
|
113
|
+
if (n === 0 && m === 0) return []
|
|
114
|
+
if (n === 0 || m === 0) return [[p, p + n, p, p + m]]
|
|
115
|
+
const max = Math.min(n + m, maxD)
|
|
116
|
+
const off = max + 1
|
|
117
|
+
const v = new Int32Array(2 * max + 3) // v[k + off]: furthest x reached on diagonal k
|
|
118
|
+
const trace = [] // v's window [-d, d] as it stood before step d
|
|
119
|
+
let found = -1
|
|
120
|
+
for (let d = 0; d <= max && found < 0; d++) {
|
|
121
|
+
trace.push(v.slice(off - d, off + d + 1))
|
|
122
|
+
for (let k = -d; k <= d; k += 2) {
|
|
123
|
+
let x = (k === -d || (k !== d && v[off + k - 1] < v[off + k + 1])) ? v[off + k + 1] : v[off + k - 1] + 1
|
|
124
|
+
let y = x - k
|
|
125
|
+
while (x < n && y < m && a[p + x] === b[p + y]) { x++; y++ }
|
|
126
|
+
v[off + k] = x
|
|
127
|
+
if (x >= n && y >= m) { found = d; break }
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
if (found < 0) return null
|
|
131
|
+
// Walk back from (n, m): each step d undoes one insertion or deletion, then its snake.
|
|
132
|
+
const steps = [] // [x0, y0, x1, y1], one edit each, collected last-first
|
|
133
|
+
let x = n, y = m
|
|
134
|
+
for (let d = found; d > 0; d--) {
|
|
135
|
+
const w = trace[d], at = (k) => w[k + d]
|
|
136
|
+
const k = x - y
|
|
137
|
+
const down = k === -d || (k !== d && at(k - 1) < at(k + 1)) // true: came by an insertion from b
|
|
138
|
+
const pk = down ? k + 1 : k - 1
|
|
139
|
+
const px = at(pk), py = px - pk
|
|
140
|
+
steps.push(down ? [px, py, px, py + 1] : [px, py, px + 1, py])
|
|
141
|
+
x = px; y = py
|
|
142
|
+
}
|
|
143
|
+
steps.reverse()
|
|
144
|
+
const hunks = []
|
|
145
|
+
for (const [x0, y0, x1, y1] of steps) {
|
|
146
|
+
const last = hunks[hunks.length - 1]
|
|
147
|
+
if (last && last[1] === p + x0 && last[3] === p + y0) { last[1] = p + x1; last[3] = p + y1 }
|
|
148
|
+
else hunks.push([p + x0, p + x1, p + y0, p + y1])
|
|
149
|
+
}
|
|
150
|
+
return hunks
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// One side's version of base lines [rs, re) with its hunks inside that range applied.
|
|
154
|
+
function sideOfRegion(b, rs, re, hunks, src) {
|
|
155
|
+
const out = []
|
|
156
|
+
let cur = rs
|
|
157
|
+
for (const [bs, be, os, oe] of hunks) {
|
|
158
|
+
for (let i = cur; i < bs; i++) out.push(b[i])
|
|
159
|
+
for (let i = os; i < oe; i++) out.push(src[i])
|
|
160
|
+
cur = be
|
|
161
|
+
}
|
|
162
|
+
for (let i = cur; i < re; i++) out.push(b[i])
|
|
163
|
+
return out
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Where else a hunk could sit and mean the same edit: a pure insertion or deletion inside a run of
|
|
167
|
+
// identical lines ("}" "}" "}") can slide along it, and which position the diff chose is an
|
|
168
|
+
// accident of the algorithm. Two sides' edits count as touching if they COULD touch, so a merge
|
|
169
|
+
// that hinges on that accident conflicts instead of guessing (git reaches the same answer by
|
|
170
|
+
// sliding every hunk to one canonical end first). Returns [lo, hi] in base lines.
|
|
171
|
+
function slideSpan(b, [bs, be, os, oe], src) {
|
|
172
|
+
let lo = bs, hi = be
|
|
173
|
+
if (os === oe && be > bs) { // pure deletion of b[bs, be)
|
|
174
|
+
let s = bs, e = be
|
|
175
|
+
while (s > 0 && b[s - 1] === b[e - 1]) { s--; e-- }
|
|
176
|
+
lo = s
|
|
177
|
+
s = bs; e = be
|
|
178
|
+
while (e < b.length && b[s] === b[e]) { s++; e++ }
|
|
179
|
+
hi = e
|
|
180
|
+
} else if (bs === be && oe > os) { // pure insertion at bs of src[os, oe)
|
|
181
|
+
const n = oe - os
|
|
182
|
+
let k = bs, r = 0 // r: how far the inserted block has rotated
|
|
183
|
+
while (k > 0 && b[k - 1] === src[os + ((n - 1 - r) % n + n) % n]) { k--; r++ }
|
|
184
|
+
lo = k
|
|
185
|
+
k = bs; r = 0
|
|
186
|
+
while (k < b.length && b[k] === src[os + (r % n)]) { k++; r++ }
|
|
187
|
+
hi = k
|
|
188
|
+
}
|
|
189
|
+
return [lo, hi]
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// diff3 over line arrays: hunks from the two sides that overlap OR TOUCH form one region (touching
|
|
193
|
+
// changes conflict, as in git: two edits with no unchanged line between them are one decision). A
|
|
194
|
+
// region only one side changed takes that side; both sides making the same change takes it once;
|
|
195
|
+
// anything else is a conflict of THAT region only. Null when either diff is past the work bound.
|
|
196
|
+
function diff3Lines(b, mine, theirs) {
|
|
197
|
+
const hm = lineHunks(b, mine), ht = lineHunks(b, theirs)
|
|
198
|
+
if (!hm || !ht) return null
|
|
199
|
+
const sm = hm.map((h) => slideSpan(b, h, mine)), st = ht.map((h) => slideSpan(b, h, theirs))
|
|
200
|
+
const out = []
|
|
201
|
+
let conflict = false, pos = 0, i = 0, j = 0
|
|
202
|
+
while (i < hm.length || j < ht.length) {
|
|
203
|
+
const fromMine = j >= ht.length || (i < hm.length && hm[i][0] <= ht[j][0])
|
|
204
|
+
const first = fromMine ? hm[i] : ht[j]
|
|
205
|
+
let reach = fromMine ? sm[i++][1] : st[j++][1] // how far this region's edits could extend
|
|
206
|
+
const rm = fromMine ? [first] : [], rt = fromMine ? [] : [first]
|
|
207
|
+
const rs = first[0]
|
|
208
|
+
let re = first[1]
|
|
209
|
+
for (;;) {
|
|
210
|
+
if (i < hm.length && sm[i][0] <= reach) { re = Math.max(re, hm[i][1]); reach = Math.max(reach, sm[i][1]); rm.push(hm[i++]); continue }
|
|
211
|
+
if (j < ht.length && st[j][0] <= reach) { re = Math.max(re, ht[j][1]); reach = Math.max(reach, st[j][1]); rt.push(ht[j++]); continue }
|
|
212
|
+
break
|
|
213
|
+
}
|
|
214
|
+
for (let x = pos; x < rs; x++) out.push(b[x])
|
|
215
|
+
if (!rt.length) out.push(...sideOfRegion(b, rs, re, rm, mine))
|
|
216
|
+
else if (!rm.length) out.push(...sideOfRegion(b, rs, re, rt, theirs))
|
|
217
|
+
else {
|
|
218
|
+
const vm = sideOfRegion(b, rs, re, rm, mine), vt = sideOfRegion(b, rs, re, rt, theirs)
|
|
219
|
+
if (vm.length === vt.length && vm.every((l, k) => l === vt[k])) out.push(...vm)
|
|
220
|
+
else { conflict = true; out.push('<<<<<<< local (yours)', ...vm, '=======', ...vt, '>>>>>>> incoming (theirs)') }
|
|
221
|
+
}
|
|
222
|
+
pos = re
|
|
223
|
+
}
|
|
224
|
+
for (let x = pos; x < b.length; x++) out.push(b[x])
|
|
225
|
+
return { lines: out, conflict }
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// Best-effort "what was defined here" across common languages — for the board.
|
|
229
|
+
function symbolsIn(text) {
|
|
230
|
+
const names = new Set()
|
|
231
|
+
const add = (re) => { let m; while ((m = re.exec(text)) && names.size < 8) names.add(m[1]) }
|
|
232
|
+
add(/\bfunction\s+(\w+)/g)
|
|
233
|
+
add(/\bclass\s+(\w+)/g)
|
|
234
|
+
add(/\bdef\s+(\w+)/g)
|
|
235
|
+
add(/\b(?:export\s+)?(?:const|let|var)\s+(\w+)\s*=/g)
|
|
236
|
+
return [...names].slice(0, 8)
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// Classify a change as a small PATCH vs wholesale REWRITE.
|
|
240
|
+
function summarizeChange(base, next) {
|
|
241
|
+
const b = (base || '').split('\n')
|
|
242
|
+
const n = (next || '').split('\n')
|
|
243
|
+
if (!base) return { isRewrite: false, changedLines: n.length, totalLines: n.length, symbols: symbolsIn(next || '') }
|
|
244
|
+
const r = changedRange(b, n)
|
|
245
|
+
const churn = Math.max(r.endBase - r.startBase, r.newLines.length)
|
|
246
|
+
const total = Math.max(b.length, n.length)
|
|
247
|
+
const isRewrite = total > 0 && churn >= 4 && churn / total >= 0.5
|
|
248
|
+
return { isRewrite, changedLines: churn, totalLines: total, symbols: symbolsIn(r.newLines.join('\n')) }
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// 3-way merge for the sync layer (disk <-> shared doc)
|
|
252
|
+
function merge3(base, mine, theirs) {
|
|
253
|
+
const norm = (s) => (s || '').replace(/\r\n/g, '\n')
|
|
254
|
+
const nm0 = norm(mine), nt0 = norm(theirs), nb0 = norm(base)
|
|
255
|
+
if (nm0 === nt0) return { text: nt0, conflict: false }
|
|
256
|
+
if (nb0 === nt0) return { text: nm0, conflict: false }
|
|
257
|
+
if (nb0 === nm0) return { text: nt0, conflict: false }
|
|
258
|
+
|
|
259
|
+
const cleanBase = hasConflictMarkers(base) ? stripConflictMarkers(base, 'local') : (base || '')
|
|
260
|
+
const cleanMine = hasConflictMarkers(mine) ? stripConflictMarkers(mine, 'local') : (mine || '')
|
|
261
|
+
const cleanTheirs = hasConflictMarkers(theirs) ? stripConflictMarkers(theirs, 'theirs') : (theirs || '')
|
|
262
|
+
|
|
263
|
+
const nm = norm(cleanMine), nt = norm(cleanTheirs), nb = norm(cleanBase)
|
|
264
|
+
if (nm === nt) return { text: nt, conflict: false }
|
|
265
|
+
if (nb === nt) return { text: nm, conflict: false }
|
|
266
|
+
if (nb === nm) return { text: nt, conflict: false }
|
|
267
|
+
|
|
268
|
+
const b = nb.split('\n')
|
|
269
|
+
const d3 = diff3Lines(b, nm.split('\n'), nt.split('\n'))
|
|
270
|
+
if (d3) return { text: d3.lines.join('\n'), conflict: d3.conflict }
|
|
271
|
+
// Past diff3's work bound (two sides that share almost nothing): one range per side.
|
|
272
|
+
const mr = changedRange(b, nm.split('\n'))
|
|
273
|
+
const tr = changedRange(b, nt.split('\n'))
|
|
274
|
+
if (mr.endBase <= tr.startBase || tr.endBase <= mr.startBase) {
|
|
275
|
+
const out = b.slice()
|
|
276
|
+
for (const e of [mr, tr].sort((x, y) => y.startBase - x.startBase)) {
|
|
277
|
+
out.splice(e.startBase, e.endBase - e.startBase, ...e.newLines)
|
|
278
|
+
}
|
|
279
|
+
return { text: out.join('\n'), conflict: false }
|
|
280
|
+
}
|
|
281
|
+
const start = Math.min(mr.startBase, tr.startBase)
|
|
282
|
+
const end = Math.max(mr.endBase, tr.endBase)
|
|
283
|
+
const mineBlock = [...b.slice(start, mr.startBase), ...mr.newLines, ...b.slice(mr.endBase, end)]
|
|
284
|
+
const theirsBlock = [...b.slice(start, tr.startBase), ...tr.newLines, ...b.slice(tr.endBase, end)]
|
|
285
|
+
if (mineBlock.length === theirsBlock.length && mineBlock.every((l, k) => l === theirsBlock[k])) {
|
|
286
|
+
const out = [...b.slice(0, start), ...theirsBlock, ...b.slice(end)]
|
|
287
|
+
return { text: out.join('\n'), conflict: false }
|
|
288
|
+
}
|
|
289
|
+
const out = [
|
|
290
|
+
...b.slice(0, start),
|
|
291
|
+
'<<<<<<< local (yours)',
|
|
292
|
+
...mineBlock,
|
|
293
|
+
'=======',
|
|
294
|
+
...theirsBlock,
|
|
295
|
+
'>>>>>>> incoming (theirs)',
|
|
296
|
+
...b.slice(end),
|
|
297
|
+
]
|
|
298
|
+
return { text: out.join('\n'), conflict: true }
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function describeConflicts(cs) {
|
|
302
|
+
return (cs || []).map((c) =>
|
|
303
|
+
c.startsWith('ref:') ? `'${c.slice(4)}' was removed or renamed but is still used`
|
|
304
|
+
: c.startsWith('fn:') ? `both sides changed function ${c.slice(3)}`
|
|
305
|
+
: c.startsWith('class:') ? `both sides changed class ${c.slice(6)}`
|
|
306
|
+
: `both sides changed ${c.replace(/^\w+:/, '')}`).join('; ')
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function icrMerge3(base, mine, theirs, relPath, icrlib) {
|
|
310
|
+
const lm = merge3(base, mine, theirs)
|
|
311
|
+
if (!icrlib || !icrlib.supports || !icrlib.supports(relPath)) return lm
|
|
312
|
+
let r
|
|
313
|
+
try { r = icrlib.structuralMerge(base, mine, theirs, { filename: relPath }) } catch (e) { return lm }
|
|
314
|
+
if (r.status === 'auto') {
|
|
315
|
+
const renamed = r.renames && r.renames.length
|
|
316
|
+
return { text: r.text, conflict: false, icr: renamed ? 'rename' : 'structural', icrWarning: renamed ? `auto-applied rename ${r.renames.join(', ')}` : undefined }
|
|
317
|
+
}
|
|
318
|
+
if (r.status === 'semantic-conflict') return Object.assign({}, lm, { icrWarning: describeConflicts(r.conflicts) })
|
|
319
|
+
return lm
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// createReconciler: binds to a session's maps, state, and callbacks, returning
|
|
323
|
+
// a reconcile(relPath, origin = 'local') function.
|
|
324
|
+
function createReconciler(ctx) {
|
|
325
|
+
const {
|
|
326
|
+
root,
|
|
327
|
+
name = 'anon',
|
|
328
|
+
SKIP = new Set(['HIVE_BOARD.md', 'HIVE_CHAT.md', 'HIVE_TASKS.md', 'HIVE_RULES.md', 'HIVE_MEMBERS.md', 'HIVE_FOR_AGENTS.md', '.hive.json']),
|
|
329
|
+
isIgnored = () => false,
|
|
330
|
+
canOpen = () => true,
|
|
331
|
+
canWrite = () => true,
|
|
332
|
+
readText = (full) => {
|
|
333
|
+
try {
|
|
334
|
+
const buf = fs.readFileSync(full)
|
|
335
|
+
if (buf.length > 1_000_000 || buf.includes(0)) return null
|
|
336
|
+
return buf.toString('utf8')
|
|
337
|
+
} catch { return null }
|
|
338
|
+
},
|
|
339
|
+
writeToDisk,
|
|
340
|
+
openFile,
|
|
341
|
+
touchFile,
|
|
342
|
+
fileDocs,
|
|
343
|
+
manifest,
|
|
344
|
+
bases,
|
|
345
|
+
forkBases,
|
|
346
|
+
known,
|
|
347
|
+
mtimes,
|
|
348
|
+
conflicted = new Set(),
|
|
349
|
+
icrWarned = new Map(),
|
|
350
|
+
snappedBase = new Set(),
|
|
351
|
+
preexisting = new Set(),
|
|
352
|
+
priorShadow,
|
|
353
|
+
shadow,
|
|
354
|
+
say = () => {},
|
|
355
|
+
log = () => {},
|
|
356
|
+
markEditing = () => {},
|
|
357
|
+
noteCoEditing = () => {},
|
|
358
|
+
noteIfRewrite = () => {},
|
|
359
|
+
captureSnapshot = () => {},
|
|
360
|
+
lastLocalEdit,
|
|
361
|
+
// Fork-gate & provenance
|
|
362
|
+
FORK_GATE = false,
|
|
363
|
+
captureLocal,
|
|
364
|
+
forkRecord,
|
|
365
|
+
forkBlock,
|
|
366
|
+
detectFork,
|
|
367
|
+
renderConflict,
|
|
368
|
+
adoptResolved,
|
|
369
|
+
stampProvenance,
|
|
370
|
+
attestHead,
|
|
371
|
+
fusionReason,
|
|
372
|
+
supports,
|
|
373
|
+
languageFor,
|
|
374
|
+
icrlib
|
|
375
|
+
} = ctx
|
|
376
|
+
|
|
377
|
+
return function reconcile(relPath, origin = 'local') {
|
|
378
|
+
if (SKIP.has(relPath) || isIgnored(relPath)) return
|
|
379
|
+
if (!canOpen(relPath)) return
|
|
380
|
+
const full = path.join(root, relPath)
|
|
381
|
+
const exists = fs.existsSync(full)
|
|
382
|
+
const disk = exists ? readText(full) : null
|
|
383
|
+
if (exists && disk === null) return
|
|
384
|
+
|
|
385
|
+
if (origin === 'local' && !canWrite(relPath)) {
|
|
386
|
+
const fe2 = fileDocs.get(relPath)
|
|
387
|
+
if (fe2 && fe2.synced) {
|
|
388
|
+
const dt = fe2.text.toString()
|
|
389
|
+
if (dt && dt !== disk) writeToDisk(relPath, dt)
|
|
390
|
+
}
|
|
391
|
+
return
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
let fe = fileDocs.get(relPath)
|
|
395
|
+
if (!fe) { if (disk === null) return; openFile(relPath); return }
|
|
396
|
+
if (!fe.synced) return
|
|
397
|
+
if (touchFile) touchFile(relPath)
|
|
398
|
+
|
|
399
|
+
if (FORK_GATE && captureLocal) captureLocal(relPath)
|
|
400
|
+
|
|
401
|
+
if (FORK_GATE && fe.conflict && forkRecord) {
|
|
402
|
+
const rec = forkRecord(fe)
|
|
403
|
+
if (rec) {
|
|
404
|
+
const block = forkBlock(fe, rec)
|
|
405
|
+
if (origin === 'local') {
|
|
406
|
+
if (disk === block) return
|
|
407
|
+
const fork0 = forkBases.get(relPath)
|
|
408
|
+
const sawMarkers = fork0 === block || hasConflictMarkers(fork0 || '')
|
|
409
|
+
if (sawMarkers && disk !== null && !hasConflictMarkers(disk)) {
|
|
410
|
+
fe.doc.transact(() => {
|
|
411
|
+
applyDiff(fe.text, disk)
|
|
412
|
+
for (const v of rec.versions) fe.forkmark.set(v.hash, 1)
|
|
413
|
+
for (const key of [...fe.conflict.keys()]) fe.conflict.delete(key)
|
|
414
|
+
})
|
|
415
|
+
if (stampProvenance) stampProvenance(relPath, block || '', disk)
|
|
416
|
+
conflicted.delete(relPath)
|
|
417
|
+
known.add(relPath); bases.set(relPath, disk); forkBases.set(relPath, disk)
|
|
418
|
+
try { say(`✓ conflict in ${relPath} resolved by ${name}.`) } catch { }
|
|
419
|
+
return
|
|
420
|
+
}
|
|
421
|
+
if (!sawMarkers && disk !== null && disk !== fork0) {
|
|
422
|
+
if (detectFork) detectFork(relPath)
|
|
423
|
+
return
|
|
424
|
+
}
|
|
425
|
+
if (renderConflict) renderConflict(relPath)
|
|
426
|
+
return
|
|
427
|
+
}
|
|
428
|
+
if (renderConflict) renderConflict(relPath)
|
|
429
|
+
return
|
|
430
|
+
}
|
|
431
|
+
if (disk !== null && hasConflictMarkers(disk) && !hasConflictMarkers(fe.text.toString())) {
|
|
432
|
+
if (adoptResolved) { adoptResolved(relPath); return }
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
const docText = fe.text.toString()
|
|
437
|
+
const noHistory = !bases.has(relPath) && !(priorShadow && priorShadow.has(relPath))
|
|
438
|
+
if (docText === '' && (!manifest.has(relPath) || (disk && noHistory))) {
|
|
439
|
+
if (disk === null) return
|
|
440
|
+
fe.doc.transact(() => applyDiff(fe.text, disk))
|
|
441
|
+
if (stampProvenance) stampProvenance(relPath, '', disk)
|
|
442
|
+
manifest.set(relPath, 1)
|
|
443
|
+
known.add(relPath); bases.set(relPath, disk); forkBases.set(relPath, disk)
|
|
444
|
+
if (shadow) shadow.set(relPath, disk)
|
|
445
|
+
try { mtimes.set(relPath, fs.statSync(full).mtimeMs) } catch { }
|
|
446
|
+
captureSnapshot(relPath, disk, name, { force: true, kind: 'base', label: 'created' })
|
|
447
|
+
snappedBase.add(relPath)
|
|
448
|
+
return
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
const yt = fe.text
|
|
452
|
+
if (disk === null) {
|
|
453
|
+
writeToDisk(relPath, docText)
|
|
454
|
+
bases.set(relPath, docText); forkBases.set(relPath, docText)
|
|
455
|
+
return
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
if (disk === docText) {
|
|
459
|
+
if (origin === 'local' && !known.has(relPath) && disk) markEditing(relPath)
|
|
460
|
+
known.add(relPath); bases.set(relPath, disk)
|
|
461
|
+
if (shadow) shadow.set(relPath, disk)
|
|
462
|
+
if (!forkBases.has(relPath) || origin === 'local') forkBases.set(relPath, disk)
|
|
463
|
+
if (!snappedBase.has(relPath) && disk) {
|
|
464
|
+
captureSnapshot(relPath, disk, name, { force: true, kind: 'base', label: 'baseline' })
|
|
465
|
+
snappedBase.add(relPath)
|
|
466
|
+
}
|
|
467
|
+
return
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
// FIRST-CONTACT ADOPT
|
|
471
|
+
if (!bases.has(relPath) && docText !== '' && disk !== docText) {
|
|
472
|
+
if (!preexisting.has(relPath)) {
|
|
473
|
+
const nl = (s) => (s === '' || s.endsWith('\n') ? s : s + '\n')
|
|
474
|
+
const r0 = icrMerge3('', nl(disk), nl(docText), relPath, icrlib)
|
|
475
|
+
if (r0.text !== docText) fe.doc.transact(() => applyDiff(yt, r0.text))
|
|
476
|
+
if (r0.text !== disk) writeToDisk(relPath, r0.text)
|
|
477
|
+
if (r0.conflict) conflicted.add(relPath)
|
|
478
|
+
bases.set(relPath, r0.text); forkBases.set(relPath, r0.text)
|
|
479
|
+
if (shadow) shadow.set(relPath, r0.text)
|
|
480
|
+
if (!manifest.has(relPath)) manifest.set(relPath, 1)
|
|
481
|
+
try { say(`⚠ ${relPath} was created by two participants at once — both versions were merged${r0.conflict ? ', and CONFLICT markers need resolving' : ''}.`) } catch { }
|
|
482
|
+
return
|
|
483
|
+
}
|
|
484
|
+
const anc = priorShadow ? priorShadow.get(relPath) : undefined
|
|
485
|
+
if (anc !== undefined && disk !== null) {
|
|
486
|
+
if (disk === anc) {
|
|
487
|
+
writeToDisk(relPath, docText)
|
|
488
|
+
bases.set(relPath, docText); forkBases.set(relPath, docText)
|
|
489
|
+
if (!snappedBase.has(relPath)) {
|
|
490
|
+
captureSnapshot(relPath, docText, name, { force: true, kind: 'base', label: 'room copy adopted on rejoin (local was unchanged)' })
|
|
491
|
+
snappedBase.add(relPath)
|
|
492
|
+
}
|
|
493
|
+
return
|
|
494
|
+
}
|
|
495
|
+
captureSnapshot(relPath, disk, name, { force: true, kind: 'manual', label: 'local copy before rejoin merge' })
|
|
496
|
+
const rj = icrMerge3(anc, disk, docText, relPath, icrlib)
|
|
497
|
+
if (rj.text !== docText) fe.doc.transact(() => applyDiff(yt, rj.text))
|
|
498
|
+
if (rj.text !== disk) writeToDisk(relPath, rj.text)
|
|
499
|
+
if (rj.conflict) conflicted.add(relPath)
|
|
500
|
+
bases.set(relPath, rj.text); forkBases.set(relPath, rj.text)
|
|
501
|
+
if (shadow) shadow.set(relPath, rj.text)
|
|
502
|
+
if (!manifest.has(relPath)) manifest.set(relPath, 1)
|
|
503
|
+
try { say(`⚠ ${relPath} changed locally while ${name} was disconnected — the local edit and the room copy were merged${rj.conflict ? ', and CONFLICT markers need resolving' : ''}. Pre-merge local copy is in the file's history.`) } catch { }
|
|
504
|
+
return
|
|
505
|
+
}
|
|
506
|
+
captureSnapshot(relPath, disk, name, { force: true, kind: 'manual', label: 'local copy at join (room copy adopted)' })
|
|
507
|
+
writeToDisk(relPath, docText)
|
|
508
|
+
bases.set(relPath, docText); forkBases.set(relPath, docText)
|
|
509
|
+
if (!snappedBase.has(relPath)) {
|
|
510
|
+
captureSnapshot(relPath, docText, name, { force: true, kind: 'base', label: 'adopted from room at join' })
|
|
511
|
+
snappedBase.add(relPath)
|
|
512
|
+
}
|
|
513
|
+
try { say(`↓ ${relPath} already existed in this room — ${name} adopted the room's copy. The local version before joining is saved in the file's history.`) } catch { }
|
|
514
|
+
return
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
const base = bases.has(relPath) ? bases.get(relPath) : disk
|
|
518
|
+
const fork = forkBases.has(relPath) ? forkBases.get(relPath) : base
|
|
519
|
+
if (origin === 'local') { noteCoEditing(relPath); markEditing(relPath) }
|
|
520
|
+
|
|
521
|
+
let res, reAdded = false
|
|
522
|
+
if (origin === 'local' && hasConflictMarkers(docText) && !hasConflictMarkers(disk)) {
|
|
523
|
+
res = { text: disk, conflict: false }
|
|
524
|
+
} else if (origin === 'local') {
|
|
525
|
+
noteIfRewrite(relPath, fork, disk)
|
|
526
|
+
const sc = summarizeChange(fork, disk)
|
|
527
|
+
const isCleanRewrite = sc.isRewrite && supports && supports(relPath) && languageFor && languageFor(relPath).parses(disk)
|
|
528
|
+
const theirsNew = changedRange(fork.split('\n'), docText.split('\n')).newLines.filter((l) => l.length)
|
|
529
|
+
const mineLines = new Set(disk.split('\n'))
|
|
530
|
+
const integrated = theirsNew.every((l) => mineLines.has(l))
|
|
531
|
+
if (integrated || (isCleanRewrite && !hasConflictMarkers(docText))) {
|
|
532
|
+
res = { text: disk, conflict: false }
|
|
533
|
+
} else {
|
|
534
|
+
res = icrMerge3(fork, disk, docText, relPath, icrlib)
|
|
535
|
+
reAdded = !res.conflict && res.text !== disk
|
|
536
|
+
}
|
|
537
|
+
} else {
|
|
538
|
+
res = icrMerge3(base, disk, docText, relPath, icrlib)
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
if (res && !res.conflict && fusionReason) {
|
|
542
|
+
const why = fusionReason(res.text, disk, docText, (supports && supports(relPath)) ? languageFor(relPath) : null)
|
|
543
|
+
if (why) {
|
|
544
|
+
conflicted.add(relPath)
|
|
545
|
+
if (icrWarned.get(relPath) !== why) {
|
|
546
|
+
icrWarned.set(relPath, why)
|
|
547
|
+
log(`[${name}] !! REFUSED a fused merge of ${relPath}: ${why}`)
|
|
548
|
+
try {
|
|
549
|
+
say(`[!] @all ${relPath}: a merge was REFUSED because it fused rather than merged — ${why}. Nothing was written: the room's copy and ${name}'s local copy are both intact and unchanged. Someone must reconcile them deliberately; a plain file write will merge against the other version again, hive_edit replaces it outright.`)
|
|
550
|
+
} catch { }
|
|
551
|
+
}
|
|
552
|
+
return
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
if (origin === 'local' && res.text !== docText) {
|
|
557
|
+
const sc = summarizeChange(docText, res.text)
|
|
558
|
+
captureSnapshot(relPath, docText, name, { force: sc.isRewrite, churn: `${sc.changedLines}/${sc.totalLines} lines` })
|
|
559
|
+
if (typeof lastLocalEdit === 'function') lastLocalEdit(relPath)
|
|
560
|
+
}
|
|
561
|
+
fe.doc.transact(() => applyDiff(yt, res.text))
|
|
562
|
+
if (origin === 'local') {
|
|
563
|
+
if (FORK_GATE) { if (res.text !== docText && attestHead) attestHead(relPath, res.text, docText) }
|
|
564
|
+
else if (res.text !== docText && stampProvenance) stampProvenance(relPath, docText, res.text)
|
|
565
|
+
}
|
|
566
|
+
if (res.text !== disk) writeToDisk(relPath, res.text)
|
|
567
|
+
known.add(relPath); bases.set(relPath, res.text)
|
|
568
|
+
if (origin === 'local') forkBases.set(relPath, res.text)
|
|
569
|
+
else if (!forkBases.get(relPath)) forkBases.set(relPath, res.text)
|
|
570
|
+
|
|
571
|
+
const hasMarkers = res.conflict || hasConflictMarkers(res.text)
|
|
572
|
+
if (hasMarkers && !conflicted.has(relPath)) {
|
|
573
|
+
conflicted.add(relPath)
|
|
574
|
+
log(`[${name}] ⚠ merge conflict in ${relPath} — kept BOTH versions with <<<<<<< markers`)
|
|
575
|
+
try { say(`⚠ MERGE CONFLICT in ${relPath} — it has <<<<<<< markers. Whoever owns it: resolve before continuing.`) } catch { }
|
|
576
|
+
} else if (!hasMarkers && conflicted.has(relPath)) {
|
|
577
|
+
conflicted.delete(relPath)
|
|
578
|
+
try { say(`✓ conflict in ${relPath} resolved.`) } catch { }
|
|
579
|
+
} else if (reAdded) {
|
|
580
|
+
log(`[${name}] protected ${relPath}: your edit was based on an older version — re-added changes that arrived since (nobody's work lost)`)
|
|
581
|
+
try { say(`↺ ${relPath}: ${name}'s edit was based on an older copy — kept the changes that landed in between (nothing lost).`) } catch { }
|
|
582
|
+
} else if (!hasMarkers && res.icr === 'rename') {
|
|
583
|
+
log(`[${name}] ICR merged ${relPath} (detected a rename and updated the call sites)`)
|
|
584
|
+
} else if (!hasMarkers && res.icr === 'structural') {
|
|
585
|
+
log(`[${name}] ICR merged ${relPath} structurally (both edits kept, syntax guaranteed)`)
|
|
586
|
+
} else if (!hasMarkers) {
|
|
587
|
+
log(`[${name}] merged ${relPath} (both edits kept)`)
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
if (res.icrWarning && icrWarned.get(relPath) !== res.icrWarning) {
|
|
591
|
+
icrWarned.set(relPath, res.icrWarning)
|
|
592
|
+
log(`[${name}] ⚠ ICR flag in ${relPath} — ${res.icrWarning}`)
|
|
593
|
+
try { say(`⚠ ICR flag in ${relPath}: ${res.icrWarning}. It merged, but review — this is a break a plain merge can't see.`) } catch { }
|
|
594
|
+
} else if (!res.icrWarning && icrWarned.has(relPath)) {
|
|
595
|
+
icrWarned.delete(relPath)
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
if (detectFork) detectFork(relPath)
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
// reconcile direct call
|
|
603
|
+
function reconcile(ctx, relPath, origin = 'local') {
|
|
604
|
+
return createReconciler(ctx)(relPath, origin)
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
module.exports = {
|
|
608
|
+
reconcile,
|
|
609
|
+
createReconciler,
|
|
610
|
+
applyDiff,
|
|
611
|
+
hasConflictMarkers,
|
|
612
|
+
stripConflictMarkers,
|
|
613
|
+
changedRange,
|
|
614
|
+
symbolsIn,
|
|
615
|
+
summarizeChange,
|
|
616
|
+
merge3,
|
|
617
|
+
lineHunks,
|
|
618
|
+
diff3Lines,
|
|
619
|
+
describeConflicts,
|
|
620
|
+
icrMerge3
|
|
621
|
+
}
|