heyquilt 0.3.138 → 0.3.140

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/RELEASES.md +6 -0
  2. package/bin/quilt.js +7 -143
  3. package/package.json +1 -1
  4. package/src/account.js +0 -29
  5. package/src/api/memory-store.js +0 -29
  6. package/src/api/routes/teams.js +0 -23
  7. package/src/api/server.js +5 -19
  8. package/src/api/supabase-store.js +0 -52
  9. package/src/branch-view.js +6 -131
  10. package/src/control.js +2 -33
  11. package/src/duties.js +1 -1
  12. package/src/github-commit.js +3 -315
  13. package/src/mcp.js +8 -157
  14. package/src/relay-ask.js +3 -66
  15. package/src/relay-cluster.js +3 -5
  16. package/src/relay-mcp.js +6 -418
  17. package/src/relay-prune.js +6 -145
  18. package/src/relay-turn-sync.js +1 -1
  19. package/src/server.js +33 -1331
  20. package/src/session.js +52 -3526
  21. package/src/status.js +2 -58
  22. package/src/turn-sync.js +15 -2
  23. package/src/ui/changes.js +5 -14
  24. package/src/ui/chat.js +0 -1
  25. package/src/ui/common.js +0 -2
  26. package/src/ui/git-rule.js +2 -30
  27. package/src/ui/home.js +3 -76
  28. package/src/ui/session.js +34 -528
  29. package/src/ui/tree.js +2 -8
  30. package/src/ui/workspaces.js +2 -122
  31. package/src/ui-server.js +1 -38
  32. package/src/api/routes/github.js +0 -380
  33. package/src/branches.js +0 -194
  34. package/src/clash.js +0 -128
  35. package/src/commit-request.js +0 -59
  36. package/src/commit.js +0 -213
  37. package/src/git-hook.js +0 -48
  38. package/src/gitignore.js +0 -49
  39. package/src/gitstate.js +0 -1069
  40. package/src/relay-ci.js +0 -111
  41. package/src/relay-clash.js +0 -214
  42. package/src/relay-commit.js +0 -923
  43. package/src/relay-filekey.js +0 -46
  44. package/src/relay-git.js +0 -274
  45. package/src/relay-githttp.js +0 -166
  46. package/src/relay-github.js +0 -109
  47. package/src/relay-outbound.js +0 -1295
  48. package/src/relay-repo-moves.js +0 -43
  49. package/src/relay-sync.js +0 -795
  50. package/src/relay-upstream.js +0 -889
  51. package/src/sync-words.js +0 -42
  52. package/src/upstream.js +0 -192
package/src/relay-sync.js DELETED
@@ -1,795 +0,0 @@
1
- // GitHub → session, through the relay's git mirror (relay-git.js). A push on GitHub (a webhook,
2
- // or the relay's own look) is fetched into the mirror and merged into the session's branch
3
- // document file by file: base, the commit the session's content is measured from; theirs, the
4
- // new head; ours, the session's content now. Every file lands at once: where both sides changed
5
- // the same lines Quilt combines them itself, the newer edit keeping those lines (upstream.js
6
- // combine; relay-clash.js tells whoever's lines lost). Only a file a member holds (or one the
7
- // relay can't read) is held aside with its own base and theirs (so a later head never
8
- // reinterprets it against another ancestor), and merged the same way once they let go. The session
9
- // is the master node: every folder and agent gets the merged files from here, and a folder
10
- // follows the relay's commit with git instead of pulling (session.js). Never a history merge;
11
- // nothing is written to GitHub here.
12
- import * as Y from 'yjs'
13
- import { noteMerged, upstreamCommits, memberForAuthor, tellAutoMerge, storeBlob, noteResolved, closeOldClash, lostLines } from './relay-clash.js'
14
- import { upstreamsOf, closeLanded, ignoreOf, parseRepo, catcherAble, RELAY_ORIGIN } from './relay-upstream.js'
15
- import { docKey, isDiff, FOLLOWS_GIT, removePath, notePresent, ownPaths, gitRuleFiles } from './branch-view.js'
16
- import { syncStateOf, runGit, GitFailed, authEnv } from './relay-git.js'
17
- import { planCatchUp, publishedOf, combine } from './upstream.js'
18
- import { unionMatcher } from './merge3.js'
19
- import { applyTextDiff } from './textdiff.js'
20
- import { looksBinary, sha1 } from './fsutil.js'
21
- import { LARGE_FILE_BYTES, MAX_TEXT_BYTES, isIgnored, isSafeRelPath } from './pathrules.js'
22
- import { HistoryLog } from './history.js'
23
- import { RELAY_BY, isRelay } from './branches.js'
24
- import { DEFAULT_KEY } from './branchdocs.js'
25
-
26
- /** With no webhook heard on a branch for this long, the relay asks GitHub for its head itself (git ls-remote: one cheap request, no API quota)… */
27
- export const BACKSTOP_MS = 60 * 1000
28
- /** …and as often while anyone is on the branch: a missed webhook is made up for within about a minute (addendum 16.13). */
29
- export const ACTIVE_BACKSTOP_MS = 60 * 1000
30
-
31
- const plural = (k, w) => `${k} ${w}${k === 1 ? '' : 's'}`
32
- const SHA = /^[0-9a-f]{40,64}$/
33
-
34
- /** The paths that differ between two commits: Map path -> A, M or D (a type change counts as M; a rename as D plus A). */
35
- export async function changedPaths (dir, base, head) {
36
- const r = await runGit(dir, ['diff-tree', '-r', '-z', '--no-renames', '--name-status', base, head])
37
- if (!r.ok) throw new GitFailed(`comparing ${base.slice(0, 7)} and ${head.slice(0, 7)}`, r.err)
38
- const parts = r.out.split('\0')
39
- const out = new Map()
40
- for (let i = 0; i + 1 < parts.length; i += 2) {
41
- const st = parts[i][0]
42
- const rel = parts[i + 1]
43
- if (!st || !rel) continue
44
- out.set(rel, st === 'A' ? 'A' : st === 'D' ? 'D' : 'M')
45
- }
46
- return out
47
- }
48
-
49
- /**
50
- * The bytes of `paths` at commit `sha` in the mirror: Map path -> Buffer, or null when the commit
51
- * has no such file. One `git cat-file --batch` for all of them.
52
- */
53
- export async function filesAt (dir, sha, paths) {
54
- const out = new Map()
55
- if (!paths.length) return out
56
- const r = await runGit(dir, ['cat-file', '--batch'], { input: paths.map((p) => `${sha}:${p}\n`).join(''), buffer: true })
57
- if (!r.ok) throw new GitFailed(`reading files at ${sha.slice(0, 7)}`, r.err)
58
- let buf = r.out
59
- let at = 0
60
- for (const rel of paths) {
61
- const nl = buf.indexOf(0x0a, at)
62
- if (nl < 0) break
63
- const line = buf.subarray(at, nl).toString('utf8')
64
- at = nl + 1
65
- if (/ missing$/.test(line) || / ambiguous$/.test(line)) { out.set(rel, null); continue }
66
- const size = Number(line.split(' ')[2])
67
- out.set(rel, Buffer.from(buf.subarray(at, at + size)))
68
- at += size + 1 // the newline after the bytes
69
- }
70
- return out
71
- }
72
-
73
- /** How many commits `head` has that `base` hasn't. */
74
- async function countBetween (dir, base, head) {
75
- const r = await runGit(dir, ['rev-list', '--count', `${base}..${head}`])
76
- const n = Number((r.out || '').trim())
77
- return Number.isFinite(n) ? n : 0
78
- }
79
-
80
- /** The remote's head for `branch`, asked with git ls-remote (one cheap request, no objects): { sha } or { sha: null } when the branch isn't there. */
81
- export async function remoteHead (dir, branch, { token = null } = {}) {
82
- const r = await runGit(dir, ['ls-remote', '--heads', 'origin', `refs/heads/${branch}`], { env: authEnv(token) })
83
- if (!r.ok) throw new GitFailed(`asking for ${branch}`, r.err)
84
- const line = r.out.split('\n').find((l) => l.endsWith(`\trefs/heads/${branch}`))
85
- return { sha: line ? line.split('\t')[0] : null }
86
- }
87
-
88
- const isAncestor = async (dir, a, b) => (await runGit(dir, ['merge-base', '--is-ancestor', a, b])).ok
89
- const hasCommit = async (dir, sha) => (await runGit(dir, ['cat-file', '-e', `${sha}^{commit}`])).ok
90
-
91
- /** A file's bytes as the branch document keys them: text, "bin:<sha1>" (its bytes in `bins`), undefined when too large, null when absent. */
92
- function keyOf (buf, rel, { bins, tooLarge }) {
93
- if (buf === null || buf === undefined) return null
94
- if (buf.length > MAX_TEXT_BYTES) { tooLarge.add(rel); return undefined }
95
- if (looksBinary(buf)) {
96
- if (buf.length > LARGE_FILE_BYTES) { tooLarge.add(rel); return undefined }
97
- const k = `bin:${sha1(buf)}`
98
- bins.set(k, buf)
99
- return k
100
- }
101
- return buf.toString('utf8')
102
- }
103
-
104
- /**
105
- * Keeps `shas` (and nothing else) under refs/quilt/keep/<room>/<key>/ in the mirror, so git's
106
- * housekeeping never drops a base the session still measures from, or a held file's ancestors.
107
- */
108
- export async function keepRefs (dir, room, key, { base, others = [] }) {
109
- const prefix = `refs/quilt/keep/${room}/${key}/`
110
- const want = new Map()
111
- if (base) want.set(`${prefix}base`, base)
112
- for (const sha of others) if (sha && sha !== base) want.set(`${prefix}${sha.slice(0, 12)}`, sha)
113
- const have = (await runGit(dir, ['for-each-ref', '--format=%(refname) %(objectname)', prefix])).out.split('\n').filter(Boolean).map((l) => l.split(' '))
114
- for (const [ref, sha] of have) if (want.get(ref) !== sha) await runGit(dir, ['update-ref', '-d', ref])
115
- for (const [ref, sha] of want) await runGit(dir, ['update-ref', ref, sha])
116
- }
117
-
118
- /**
119
- * One look at session branch `key` through the mirror (the caller checked relayLooks). Fetches the
120
- * branch, and when the head moved on from the sync state's base, merges every changed file into
121
- * the branch document, combining what both sides changed (the newer edit keeps the lines both
122
- * changed: GitHub's commit time against the session's last edit of the file); files a member
123
- * holds are held with their own base and theirs, and merged once they let go. Returns what
124
- * happened: { state, ... } (for tests and the log). Never throws.
125
- */
126
- export async function bringInBranch (room, key, { log = () => {}, now = () => Date.now(), inQueue = false } = {}) {
127
- const recs = upstreamsOf(room)
128
- let rec = recs[key]
129
- if (!rec || !rec.repo || !rec.ref) return { state: 'unknown' }
130
- const state = syncStateOf(room, key)
131
- // The commit a folder said the session's files are at: taken on its word until GitHub confirms it (an ancestor of its head) below.
132
- if (!state.base && rec.sha) { state.base = rec.sha; state.baseFromFolder = true }
133
- if (!state.base) return { state: 'unknown' }
134
- const started = now()
135
- rec.checkedAt = started
136
- // A member's presence replaces the record (noteFolders) at any time: what matters is that it still names the same repository and branch.
137
- const { repo: repo0, ref: ref0 } = rec
138
- const current = () => { const r = recs[key]; if (r && r.repo === repo0 && r.ref === ref0) { rec = r; rec.checkedAt = started; return true } return false }
139
- const said = (problem) => {
140
- if (problem && rec.problem !== problem) log(`[${room.name}] ${key}: ${problem}`)
141
- if (problem) rec.problem = problem; else delete rec.problem
142
- }
143
- const done = (out) => { if (!room.destroyed) { room.saveMeta(); room.broadcastBranches() } return out }
144
- const repo = parseRepo(`https://${rec.repo}`)
145
- if (!repo || !repo.github) { said(`the relay only brings in commits from GitHub, and ${rec.repo.split('/')[0]} isn't GitHub: a member's folder on ${key} brings them in`); return done({ state: 'not-github' }) }
146
- const mirrors = room.mirrors
147
- let dir, head, token
148
- try {
149
- token = await room.gitToken(repo)
150
- dir = (await mirrors.ensure(repo, { url: room.gitRemoteUrl(repo) })).dir
151
- } catch (err) { said(`the relay couldn't open its mirror of ${rec.repo} (${String(err.message).slice(0, 120)})`); return done({ state: 'error' }) }
152
- // `inQueue`: the caller already holds the branch's queue (outbound, bringing the remote in before its push).
153
- const queue = inQueue ? (r, b, fn) => fn() : mirrors.queue
154
- // After every look: files the session changed that GitHub's commit now has exactly as the session
155
- // does (someone committed and pushed them) aren't uncommitted any more.
156
- const run = (r, b, fn) => queue(r, b, async () => {
157
- const out = await fn()
158
- try { if (dir && await dropCommitted(room, key, state, dir) && !room.destroyed) { room.saveMeta(); room.broadcastBranches() } } catch (err) { log(`[${room.name}] ${key}: could not compare the uncommitted files with GitHub: ${err.message}`) }
159
- return out
160
- })
161
- return run(repo.slug, rec.ref, async () => {
162
- if (state.op) { const { recoverOp } = await import('./relay-outbound.js'); await recoverOp(room, key, { dir, token }) }
163
- let base = state.base
164
- let probe = null
165
- try {
166
- // The head first, cheaply: when it hasn't moved and nothing is held, there is nothing to fetch.
167
- const r = await remoteHead(dir, rec.ref, { token })
168
- if (r.sha === base && !state.held.length) { head = base; probe = 'ls-remote' } else head = (await mirrors.fetch(dir, rec.ref, { token })).sha
169
- } catch (err) {
170
- if (err.kind === 'auth') { said(token ? `the relay's GitHub credentials can't read ${rec.repo}: ask the session owner to connect a GitHub account that can` : `the relay can't read ${rec.repo}: ask the session owner to connect GitHub`); return done({ state: token ? 'token-refused' : 'private' }) }
171
- if (err.kind === 'missing') { said(`${rec.repo} isn't on GitHub (renamed, removed, or Quilt isn't installed on it)`); return done({ state: 'missing' }) }
172
- said(`the relay couldn't reach GitHub for ${rec.name} (${String(err.message).slice(0, 120)}); it tries again later`)
173
- return done({ state: 'error', unreachable: true })
174
- }
175
- current()
176
- delete rec.backoffMs; delete rec.backoffUntil
177
- rec.head = head
178
- if (!head) {
179
- // A branch cut in the session and not pushed yet: it starts on GitHub with its first commit (relay-outbound.js).
180
- if (!state.pushed && rec.cutFrom) { said(null); return done({ state: 'not-yet' }) }
181
- if (key !== (room.resolveKey ? room.resolveKey(DEFAULT_KEY) : DEFAULT_KEY) && key !== room.defaultKey) {
182
- rec.gone = started
183
- said(`${rec.name} isn't on GitHub any more (deleted after its merge?): nothing more comes in; the branch stays in the session as it is`)
184
- return done({ state: 'gone' })
185
- }
186
- said(`${rec.name} isn't on GitHub (not pushed yet?): the relay waits for it`)
187
- return done({ state: 'not-on-github' })
188
- }
189
- if (room.ended || room.destroyed || !room.meta.branches[key] || !current() || state.base !== base) return done({ state: 'moved-on' })
190
- // A folder in step with the session said its files are at a newer commit of GitHub's (relay-upstream.js noteFolders):
191
- // an older app's folder brought it in itself, or a pull was shared from a folder. The base follows that commit, so
192
- // nothing it brought is measured again, and commits go on top of it.
193
- if (rec.sha && rec.sha !== base && head !== base && await hasCommit(dir, rec.sha) && await isAncestor(dir, base, rec.sha) && await isAncestor(dir, rec.sha, head)) {
194
- log(`[${room.name}] ${key}: the session is at ${rec.sha.slice(0, 7)}, as a folder there says (it brought those commits in); measuring from it`)
195
- state.base = base = rec.sha
196
- delete state.baseFromFolder
197
- }
198
- const e = room.branchDoc(key)
199
- const bins = new Map()
200
- const tooLarge = new Set()
201
- const disk = (rel) => docKey(e, rel)
202
- let landed = 0
203
- let released = []
204
- // Held files first, each against its own base and theirs: a claim let go of (and an older relay's clash) merged.
205
- try {
206
- const r = await settleHeld(room, key, { e, state, rec, dir, disk, bins, tooLarge, now: started, log })
207
- landed += r.landed; released = r.released
208
- } catch (err) { log(`[${room.name}] ${key}: could not look at the held files: ${err.message}`) }
209
- // A diff branch (branch-view.js): its entries that started from an older commit than the base take what the commits since changed.
210
- if (isDiff(e)) {
211
- try { landed += await liftEntries(room, key, { e, dir, base, now: started, log }) } catch (err) { log(`[${room.name}] ${key}: could not bring the session's files up to ${base.slice(0, 7)}: ${err.message}`) }
212
- }
213
- // An older app's folder on the branch brings GitHub's new commits in itself (relay-upstream.js catcherAble):
214
- // bringing them in here too would share them twice. Held files are the relay's own (above); nothing else is
215
- // written, and that folder's word moves the base once it has them.
216
- const catcher = head !== base ? catcherAble(room, key) : null
217
- if (catcher) { said(null); return done({ state: 'folder-brings', head, by: catcher, landed, released }) }
218
- if (head === base) {
219
- // The session is at GitHub's head: the record that folders follow (session.js memberAt) says so too.
220
- if (rec.sha !== head) { rec.sha = head; rec.relayHead = head }
221
- await keepRefs(dir, room.name, key, { base, others: keptObjects(state) })
222
- if (!state.held.some((h) => h.why === 'claimed' || h.why === 'unread')) { said(null); closeLanded(room, key, rec, `Brought in at ${head.slice(0, 7)}: the session on ${key} is at ${rec.name} ${head.slice(0, 7)}.`) }
223
- return done({ state: 'up-to-date', head, landed, released, ...(probe ? { probe } : {}) })
224
- }
225
- // A folder's commit that isn't on GitHub (not pushed, or dropped) is never what the session is measured from: the
226
- // commit of GitHub's that folder was on (rec.tip, from the same report) is, when GitHub's head descends from it, so
227
- // what GitHub has since still comes in. Following the head without it would leave GitHub's changes out of the session.
228
- const onTip = async () => {
229
- const tip = rec.tip
230
- if (!tip || tip === base || !(await hasCommit(dir, tip)) || !(await isAncestor(dir, tip, head))) return false
231
- log(`[${room.name}] ${key}: ${base.slice(0, 7)} (a folder's commit) isn't on ${rec.name}; measuring from ${tip.slice(0, 7)}, the commit of GitHub's that folder was on`)
232
- state.base = base = tip; delete state.baseFromFolder
233
- return true
234
- }
235
- if (!(await hasCommit(dir, base)) && !(await onTip())) {
236
- // The recorded commit was never on GitHub (a folder's local commit, dropped since): follow the head, bringing nothing in from a commit nobody has.
237
- state.base = head; rec.sha = head; delete rec.relayHead; rec.recoveredAt = started
238
- await keepRefs(dir, room.name, key, { base: head })
239
- log(`[${room.name}] ${key}: ${base.slice(0, 7)} (the commit the session's files were measured from) isn't on GitHub; following ${rec.name} at ${head.slice(0, 7)} now`)
240
- said(null)
241
- return done({ state: 'recovered', head })
242
- }
243
- // A base taken on a folder's word that GitHub's head doesn't descend from was never GitHub's (a folder's local
244
- // commit, not pushed yet): it is no rewrite. The session follows GitHub's head, bringing nothing in from it.
245
- if (state.baseFromFolder) {
246
- if (!(await isAncestor(dir, base, head)) && !(await onTip())) {
247
- state.base = head; rec.sha = head; delete rec.relayHead; delete state.baseFromFolder
248
- await keepRefs(dir, room.name, key, { base: head, others: keptObjects(state) })
249
- log(`[${room.name}] ${key}: ${base.slice(0, 7)} (a folder's commit) isn't on ${rec.name}; following it at ${head.slice(0, 7)}`)
250
- said(null)
251
- return done({ state: 'recovered', head })
252
- }
253
- delete state.baseFromFolder
254
- }
255
- // §5.3: a head that doesn't descend from the base is a rewrite (a force-push). The same 3-way merge, against the base
256
- // the mirror kept, moves the session's uncommitted work onto the new history; what was pushed before and undone on
257
- // GitHub follows GitHub. The session's work is then pushed on top of the new head as a normal commit (relay-outbound.js).
258
- const rewritten = !(await isAncestor(dir, base, head))
259
- let changes, baseMap, theirs, behind
260
- try {
261
- behind = await countBetween(dir, base, head)
262
- const all = await changedPaths(dir, base, head)
263
- const extra = []
264
- for (const rel of ['.quiltignore', ...[...all.keys()].filter((p) => /(^|\/)\.(quilt|cowove)ignore$/.test(p))]) {
265
- const f = (await filesAt(dir, head, [rel])).get(rel)
266
- if (f && f.length <= 64 * 1024) extra.push([rel, f.toString('utf8')])
267
- }
268
- // A diff branch leaves its unchanged ignore files to git: the commit's are the ones that count.
269
- if (isDiff(e)) extra.push(...await gitRuleFiles(dir, head, e, IGNORE_FILE))
270
- const ig = ignoreOf(e, extra)
271
- // A diff branch's files the session never changed follow git (branch-view.js): the commits bring them with no write.
272
- changes = new Map([...all].filter(([rel]) => isSafeRelPath(rel) && !isIgnored(ig, rel) && disk(rel) !== FOLLOWS_GIT))
273
- const paths = [...changes.keys()]
274
- const atBase = await filesAt(dir, base, paths.filter((p) => changes.get(p) !== 'A'))
275
- const atHead = await filesAt(dir, head, paths.filter((p) => changes.get(p) !== 'D'))
276
- baseMap = new Map(paths.map((rel) => [rel, changes.get(rel) === 'A' ? null : keyOf(atBase.get(rel), rel, { bins, tooLarge })]))
277
- theirs = new Map(paths.map((rel) => [rel, changes.get(rel) === 'D' ? null : keyOf(atHead.get(rel), rel, { bins, tooLarge })]))
278
- } catch (err) {
279
- said(`the relay couldn't read ${rec.name}'s changes (${String(err.message).slice(0, 120)}); it tries again later`)
280
- return done({ state: 'error', head })
281
- }
282
- if (room.ended || room.destroyed || !room.meta.branches[key] || !current() || state.base !== base) return done({ state: 'moved-on' })
283
- // A held file that changed again on GitHub keeps its base and takes the newest head as theirs.
284
- for (const h of state.held) {
285
- if (!changes.has(h.path)) continue
286
- h.theirs = head; h.head = head; changes.delete(h.path)
287
- }
288
- const heldPaths = new Set(state.held.map((h) => h.path))
289
- const published = publishedOf(room.doc)
290
- const why = (c) => tooLarge.has(c.path) ? { ...c, why: `too large for the relay to bring in (binaries over ${LARGE_FILE_BYTES / 1024} KB, text over ${MAX_TEXT_BYTES / 1024 / 1024} MB): a member's folder brings it in` } : c
291
- // Files both sides changed: who changed each last, on each side, settles which edit is the newer (combine).
292
- const union = await unionFor(e, dir, head)
293
- const tops = new Map()
294
- const parties = new Map()
295
- for (const rel of changes.keys()) {
296
- const b = baseMap.get(rel); const o = disk(rel); const t = theirs.get(rel)
297
- if (b === undefined || o === undefined || t === undefined || o === b || o === t || t === b) continue
298
- tops.set(rel, (await upstreamCommits(dir, base, head, rel))[0] || null)
299
- parties.set(rel, sessionPartyOf(e, rel))
300
- }
301
- if (room.ended || room.destroyed || !room.meta.branches[key] || !current() || state.base !== base) return done({ state: 'moved-on' })
302
- // A commit's time is in whole seconds: it counts as made at the end of its second, so a commit
303
- // and an edit in the same second go to the commit.
304
- const oursNewer = (rel) => ((parties.get(rel) || {}).ts || 0) > ((tops.get(rel) || {}).ts || 0) + 999
305
- let plan = planCatchUp({ changes, base: baseMap, theirs, disk, published, oursNewer, union })
306
- // Only files the relay couldn't read are left: held (never overwritten with the session's copy), and everything else lands.
307
- const unread = plan.conflicts.map(why)
308
- if (unread.length) {
309
- plan = planCatchUp({ changes: new Map([...changes].filter(([rel]) => !unread.some((c) => c.path === rel))), base: baseMap, theirs, disk, published, oursNewer, union })
310
- for (const c of unread) if (!heldPaths.has(c.path)) state.held.push({ path: c.path, why: 'unread', base, theirs: head, head, since: started })
311
- }
312
- // Planned against the session's content as it is now: a file edited before the writes is held and merged on the next look (below).
313
- const captured = new Map([...plan.writes.keys(), ...plan.merged.map((m) => m.path)].map((rel) => [rel, disk(rel)]))
314
- // A file someone holds is never written under them: held with its own base and theirs until they let go (Room.noteClaimsChanged looks again).
315
- const claimed = []
316
- for (const rel of plan.writes.keys()) {
317
- const c = room.claimFor ? room.claimFor(rel, key) : null
318
- if (c) claimed.push({ rel, by: c.by, pattern: c.pattern })
319
- }
320
- for (const { rel, by, pattern } of claimed) {
321
- plan.writes.delete(rel)
322
- if (!heldPaths.has(rel)) {
323
- state.held.push({ path: rel, why: 'claimed', by, base, theirs: head, head, since: started })
324
- // Named as the claim is (a folder or glob claim covers the file), so the release they run lets go of it.
325
- if (room.postChat) room.postChat({ by: RELAY_BY, to: by, kind: 'nudge', text: `${rel} changed on GitHub (${head.slice(0, 7)}) while you hold it${pattern && pattern !== rel ? ` (your claim on ${pattern})` : ''}: the change comes in, merged with yours, when you let go of it (quilt release ${pattern || rel}). Nothing of yours is overwritten.` })
326
- }
327
- rec.waitingOn = { path: rel, by, pattern }
328
- }
329
- if (!claimed.length) delete rec.waitingOn
330
- // The session's version of a file whose lines lost is kept in the mirror too (a binary's history has no bytes), for RESOLVED_KEEP_MS.
331
- const gh = (rel) => { const top = tops.get(rel); return top ? { name: top.name, email: top.email, ts: top.ts, sha: top.sha, subject: top.subject, member: memberForAuthor(room, top) } : null }
332
- const merges = plan.merged.filter((m) => !claimed.some((c) => c.rel === m.path))
333
- for (const m of merges) {
334
- if (!m.lost.some((l) => l.side === 'ours')) continue
335
- const buf = sessionBuf(e, m.path)
336
- m.oursBlob = buf ? await storeBlob(dir, buf).catch(() => null) : null
337
- }
338
- crashPoint(room, 'merge')
339
- // Addendum 16.1: each write was planned against the session's content as it was then. A file edited since (the awaits above
340
- // let edits in) is not overwritten: it is held against its base and theirs, and merged with the newer edit on the next look.
341
- const raced = new Set()
342
- for (const rel of [...plan.writes.keys()]) {
343
- if (disk(rel) === captured.get(rel)) continue
344
- raced.add(rel)
345
- plan.writes.delete(rel)
346
- if (!state.held.some((h) => h.path === rel)) state.held.push({ path: rel, why: 'raced', base, theirs: head, head, since: started })
347
- }
348
- const landedMerges = merges.filter((m) => !raced.has(m.path))
349
- // Files both sides changed, merged on the way in: on record with both parties, so failing checks after the push tell them (relay-clash.js).
350
- for (const m of landedMerges) noteMerged(room, key, m.path, { session: parties.get(m.path) || null, github: gh(m.path) }, { now: started })
351
- const written = applyWrites(room, e, plan.writes, { bins, now: started, base: head })
352
- // A diff branch: every entry that was measured from the old base is measured from the new one now (what the commits changed is in it).
353
- if (isDiff(e)) moveFileBases(e, base, head)
354
- // Whoever's lines lost is told (a direct message for the session's, the activity log for GitHub's); the losing version is kept.
355
- for (const m of landedMerges) {
356
- if (m.oursBlob) noteResolved(room, key, { path: m.path, kind: 'auto', base, theirs: head, oursBlob: m.oursBlob, how: `merged by Quilt: ${rec.name}'s newer edit kept`, at: started })
357
- tellAutoMerge(room, key, { path: m.path, lost: m.lost, deleted: m.deleted || null, session: parties.get(m.path) || null, github: gh(m.path), upstream: rec.name, head, now: started })
358
- }
359
- landed += written
360
- // The files that are now GitHub's version exactly are marked so (the Files tree, quilt_sync_status), until
361
- // someone edits them in the session (server.js wireBranch).
362
- await markFromGithub(e, dir, base, head, plan.writes, theirs).catch((err) => log(`[${room.name}] ${key}: could not mark the files from GitHub: ${err.message}`))
363
- // Addendum 16.9: the session's files reach the disk before the base says they are in.
364
- if (written && room.store && room.store.save) room.store.save(e)
365
- crashPoint(room, 'save')
366
- state.base = head
367
- if (state.protected && state.protected.head && !(await isAncestor(dir, head, state.protected.head))) {
368
- // The target moved past the live branch's start. Only its pull request merging finishes the live branch (a merge
369
- // commit that took the live head says so here; a squash or rebase, GitHub's pull request): outbound looks with its
370
- // next run (relay-outbound.js liveFate). Other work merged into the target leaves the pull request as it is.
371
- if (state.protected.merged || (await isAncestor(dir, state.protected.head, head))) {
372
- if (!state.protected.merged) state.protected.merged = { at: started }
373
- rec.problem = `${rec.ref} is protected: the live pull request is merged; commits go through a new one (opened with the next commit)`
374
- }
375
- if (room.outbound && room.outbound.schedule) room.outbound.schedule(key)
376
- }
377
- rec.sha = head
378
- rec.relayHead = head
379
- if (behind || written) rec.brought = { count: behind, files: written, at: started, head }
380
- await keepRefs(dir, room.name, key, { base: head, others: keptObjects(state) })
381
- if (rewritten) {
382
- // The session's own work on the new history: pending, so the next batch pushes it on top of the new head.
383
- for (const rel of changes.keys()) if (!heldPaths.has(rel) && disk(rel) !== undefined && disk(rel) !== (theirs.has(rel) ? theirs.get(rel) : null)) state.pending[rel] = started
384
- if (room.outbound && Object.keys(state.pending).length) room.outbound.schedule(key)
385
- }
386
- const detail = rewritten ? `${rec.name.split('/')[0] || 'origin'} rewrote ${rec.ref} (force-push); your uncommitted work was moved onto it · ${plural(written, 'file')}` : `${plural(behind, 'commit')} from ${rec.name} · ${plural(written, 'file')}`
387
- if (rewritten && room.postChat) room.postChat({ by: RELAY_BY, text: `${rec.name} was rewritten on GitHub (a force-push to ${head.slice(0, 7)}): the session follows it, and your uncommitted work on ${key} was moved onto the new history${Object.keys(state.pending).length ? ' and goes on top of it with the next commit' : ''}.`, kind: 'commit' })
388
- room.doc.transact(() => {
389
- room.activity.push([{ by: RELAY_BY, path: '', kind: 'brought', detail, branch: key, ts: started }])
390
- if (room.activity.length > 300) room.activity.delete(0, room.activity.length - 300)
391
- }, RELAY_ORIGIN)
392
- const combined = landedMerges.filter((m) => m.lost.length || m.deleted)
393
- log(`[${room.name}] ${key}: ${RELAY_BY} brought in ${detail} (${base.slice(0, 7)}..${head.slice(0, 7)})${combined.length ? `; combined ${combined.map((m) => `${m.path} (${m.deleted ? 'kept the edit over a deletion' : `the ${m.oursNewer ? 'session\'s' : 'GitHub\'s'} newer lines kept`})`).join(', ')}` : ''}${state.held.length ? `; held: ${state.held.map((h) => `${h.path} (${h.why})`).join(', ')}` : ''}`)
394
- const unreadHeld = state.held.filter((h) => h.why === 'unread')
395
- if (claimed.length) {
396
- said(`${claimed[0].by} holds ${claimed[0].rel}: the relay brings ${rec.name}'s change to it in once they let go of it (quilt_release, or quilt_handoff); everything else is in`)
397
- } else if (unreadHeld.length) {
398
- said(`${unreadHeld.map((h) => h.path).slice(0, 4).join(', ')}${unreadHeld.length > 4 ? ' and more' : ''}: too large for the relay to read, so a member's folder brings ${unreadHeld.length === 1 ? 'it' : 'them'} in; everything else is in`)
399
- } else {
400
- said(null)
401
- closeLanded(room, key, rec, `Brought in at ${head.slice(0, 7)}: ${RELAY_BY} merged ${plural(behind, 'commit')} from ${rec.name} into ${key}${released.length ? `, keeping the merged ${released.join(', ')}` : ''}.`)
402
- }
403
- return done({ state: 'brought', head, count: behind, files: written, held: state.held.map((h) => ({ path: h.path, why: h.why })), released, merged: landedMerges.map((m) => ({ path: m.path, lost: m.lost.length ? m.lost[0].side : null, ...(m.deleted ? { deleted: m.deleted } : {}) })) })
404
- })
405
- }
406
-
407
- /** What the mirror keeps for a branch besides its base: every held file's base and theirs, its ours blob, and the same for clashes settled within RESOLVED_KEEP_MS (relay-clash.js), for recovery and undo. */
408
- export function keptObjects (state) {
409
- const out = []
410
- for (const h of state.held || []) out.push(h.base, h.theirs, h.oursBlob, h.candidate && h.candidate.blob)
411
- for (const h of state.held || []) out.push(h.baseBlob, h.otherBlob)
412
- for (const r of state.resolved || []) out.push(r.base, r.theirs, r.oursBlob, r.candidateBlob, r.otherBlob, r.baseBlob)
413
- return [...new Set(out.filter((x) => typeof x === 'string' && /^[0-9a-f]{40}$/.test(x)))]
414
- }
415
-
416
- /** The member who last changed `rel` on the branch (not the relay, not a pull), by time. */
417
- function sessionPartyOf (e, rel) {
418
- let best = null
419
- for (const h of e.doc.getArray('history').toArray()) if (h && h.path === rel && !h.pulled && !h.seed && h.by && !isRelay(h.by) && (!best || (h.ts || 0) >= best.ts)) best = { name: h.by, ts: h.ts || 0 }
420
- return best
421
- }
422
-
423
- /**
424
- * Drops from `state.pending` the files whose session content is the same as at the session's base
425
- * commit on GitHub (a person committed and pushed them with git): they aren't uncommitted any more.
426
- * So are files git now ignores and GitHub's commit doesn't have (a .gitignore that came in with
427
- * git, say): no commit would take them, and only one of the relay's own used to drop them.
428
- * Returns how many were dropped. Nothing while a commit of the relay's own is under way, or for a held file.
429
- */
430
- export async function dropCommitted (room, key, state, dir) {
431
- const paths = Object.keys(state.pending || {})
432
- if (!paths.length || !state.base || state.op || !room.branchDoc) return 0
433
- const e = room.branchDoc(key)
434
- if (!e) return 0
435
- const held = new Set((state.held || []).map((h) => h.path))
436
- const at = await filesAt(dir, state.base, paths.filter((rel) => !held.has(rel)))
437
- const opts = { bins: new Map(), tooLarge: new Set() }
438
- let n = 0
439
- const untracked = []
440
- for (const [rel, buf] of at) {
441
- if (buf === null) untracked.push(rel)
442
- const mine = docKey(e, rel)
443
- const theirs = keyOf(buf, rel, opts)
444
- // FOLLOWS_GIT: a diff branch leaves it to git, so it is as the commit has it.
445
- if (mine !== FOLLOWS_GIT && (mine === undefined || theirs === undefined || mine !== theirs)) continue
446
- delete state.pending[rel]
447
- n++
448
- }
449
- // git's rule: a file the commit tracks is committed even when a .gitignore matches it, so only untracked ones.
450
- const left = untracked.filter((rel) => state.pending[rel])
451
- if (left.length) {
452
- const { ignoredPaths } = await import('./relay-outbound.js')
453
- for (const rel of await ignoredPaths(dir, e, left, state.base)) { delete state.pending[rel]; n++ }
454
- }
455
- return n
456
- }
457
-
458
- /**
459
- * The newest PENDING_FILES of the paths changed in the session since its last commit: { path, at, by? },
460
- * `by` the member who last changed it (from the branch's history, when the branch is loaded).
461
- */
462
- export const PENDING_FILES = 20
463
- export function pendingFilesOf (e, pending) {
464
- const list = Object.entries(pending || {}).sort((a, b) => (b[1] || 0) - (a[1] || 0) || (a[0] < b[0] ? -1 : 1)).slice(0, PENDING_FILES)
465
- const want = new Set(list.map(([p]) => p))
466
- const last = new Map()
467
- if (e) for (const h of e.doc.getArray('history').toArray()) {
468
- if (!h || !want.has(h.path) || h.pulled || h.seed || !h.by || isRelay(h.by)) continue
469
- const was = last.get(h.path)
470
- if (!was || (h.ts || 0) >= was.ts) last.set(h.path, { by: h.by, ts: h.ts || 0 })
471
- }
472
- return list.map(([path, at]) => ({ path, at: at || 0, ...(last.has(path) ? { by: last.get(path).by } : {}) }))
473
- }
474
-
475
- /** The merge=union matcher from the repository's .gitattributes files: the session's copy, else the commit's. */
476
- async function unionFor (e, dir, head) {
477
- const texts = []
478
- for (const [rel, t] of e.files) if (rel === '.gitattributes' || rel.endsWith('/.gitattributes')) texts.push([rel.includes('/') ? rel.slice(0, rel.lastIndexOf('/')) : '', t.toString()])
479
- if (!texts.length && head) { const f = (await filesAt(dir, head, ['.gitattributes'])).get('.gitattributes'); if (f) texts.push(['', f.toString('utf8')]) }
480
- return unionMatcher(texts)
481
- }
482
-
483
- /** The newest commit in base..head that touched each of `paths`: path -> { sha, by, at, subject }, in one git log. */
484
- async function lastCommits (dir, base, head, paths) {
485
- const want = new Set(paths)
486
- const out = new Map()
487
- if (!want.size) return out
488
- const r = await runGit(dir, ['log', '--format=%x1e%H%x1f%an%x1f%at%x1f%s', '--name-only', '--no-renames', `${base}..${head}`])
489
- if (!r.ok) return out
490
- for (const chunk of r.out.split('\x1e')) {
491
- const [meta, ...names] = chunk.split('\n')
492
- const [sha, by, at, subject] = (meta || '').split('\x1f')
493
- if (!/^[0-9a-f]{40}$/.test(sha || '')) continue
494
- for (const rel of names) if (want.has(rel) && !out.has(rel)) out.set(rel, { sha, by: (by || '').slice(0, 100), at: Number(at) * 1000 || 0, subject: (subject || '').slice(0, 200) })
495
- }
496
- return out
497
- }
498
-
499
- /**
500
- * Marks the files a look brought in as GitHub's version exactly (branch document map `fromGithub`: path ->
501
- * { sha, by, at, subject }, the commit that last changed it, and came: when it came into the session); a file
502
- * merged with the session's work, or deleted, is not. The mark goes when the file is next edited in the session (server.js wireBranch).
503
- */
504
- export async function markFromGithub (e, dir, base, head, writes, theirs) {
505
- const pure = [...writes].filter(([rel, k]) => k !== null && k === theirs.get(rel)).map(([rel]) => rel)
506
- const commits = await lastCommits(dir, base, head, pure)
507
- const map = e.doc.getMap('fromGithub')
508
- const came = Date.now()
509
- e.doc.transact(() => {
510
- for (const [rel, k] of writes) {
511
- const c = commits.get(rel)
512
- // Marked again for the same commit (a folder's bring-in after the relay's own): it came in when it first did.
513
- const was = map.get(rel)
514
- if (k !== null && k === theirs.get(rel) && c) map.set(rel, { ...c, came: was && was.sha === c.sha && was.came ? was.came : came })
515
- else if (map.has(rel)) map.delete(rel)
516
- }
517
- }, RELAY_ORIGIN)
518
- }
519
-
520
- /**
521
- * A folder brought GitHub's commits `from`..`to` into branch `key` itself (an app older than 0.3.65, or a
522
- * pull it shared): the files of those commits that the session now holds exactly as GitHub has them are
523
- * marked as from GitHub, as the relay's own bring-in marks them. Runs in the branch's queue; never throws.
524
- */
525
- export async function markFolderBrought (room, key, from, to, { log = () => {} } = {}) {
526
- try {
527
- const rec = upstreamsOf(room)[key]
528
- const e = room.store && room.store.get ? room.store.get(key) : null
529
- if (!rec || !rec.repo || !rec.ref || !e || !room.mirrors) return
530
- const repo = parseRepo(`https://${rec.repo}`)
531
- if (!repo || !repo.github) return
532
- const token = await room.gitToken(repo)
533
- const dir = (await room.mirrors.ensure(repo, { url: room.gitRemoteUrl(repo) })).dir
534
- await room.mirrors.queue(repo.slug, rec.ref, async () => {
535
- if (!(await hasCommit(dir, to))) await room.mirrors.fetch(dir, rec.ref, { token })
536
- if (!(await hasCommit(dir, from)) || !(await hasCommit(dir, to)) || !(await isAncestor(dir, from, to))) return
537
- const changes = await changedPaths(dir, from, to)
538
- const paths = [...changes.keys()].filter((rel) => isSafeRelPath(rel))
539
- const atHead = await filesAt(dir, to, paths.filter((rel) => changes.get(rel) !== 'D'))
540
- const bins = new Map(); const tooLarge = new Set()
541
- const theirs = new Map(paths.map((rel) => [rel, changes.get(rel) === 'D' ? null : keyOf(atHead.get(rel), rel, { bins, tooLarge })]))
542
- // What the session holds now stands for the write: GitHub's version exactly is marked, anything else isn't.
543
- const held = new Map(paths.filter((rel) => docKey(e, rel) !== FOLLOWS_GIT).map((rel) => [rel, docKey(e, rel) ?? null]))
544
- await markFromGithub(e, dir, from, to, held, theirs)
545
- })
546
- } catch (err) { log(`[${room.name}] ${key}: could not mark the files a folder brought in from GitHub: ${err.message}`) }
547
- }
548
-
549
- /** The files of a branch marked as GitHub's version (markFromGithub), newest first: [{ path, sha, by, at, subject }]. */
550
- export function fromGithubOf (e) {
551
- const out = []
552
- try { e.doc.getMap('fromGithub').forEach((v, path) => { if (v && typeof v.sha === 'string') out.push({ path, ...v }) }) } catch {}
553
- return out.sort((a, b) => (b.at || 0) - (a.at || 0) || (a.path < b.path ? -1 : 1))
554
- }
555
-
556
- /** Writes `writes` (path -> key: text, "bin:<sha1>" or null) into the branch document as the relay, in one transaction; how many. */
557
- export function applyWrites (room, e, writes, { bins, now, base = null }) {
558
- if (!writes.size) return 0
559
- const history = e.historyLog || (e.historyLog = new HistoryLog(e.doc, e.doc.getArray('history'), { origin: RELAY_ORIGIN }))
560
- const diff = isDiff(e)
561
- let written = 0
562
- e.doc.transact(() => {
563
- for (const [rel, k] of writes) {
564
- const before = e.files.get(rel)?.toString()
565
- const existed = e.files.has(rel) || e.blobs.has(rel)
566
- // A diff branch keeps the commit each entry is measured from (branch-view.js): `base`, the commit these writes bring.
567
- if (diff && base && k !== undefined) e.fileBases.set(rel, base)
568
- if (k === null) {
569
- if (!existed) continue
570
- if (diff) removePath(e, rel, { by: RELAY_BY, at: now })
571
- else { e.files.delete(rel); e.blobs.delete(rel) }
572
- history.record({ by: RELAY_BY, path: rel, kind: 'deleted', before, after: '', pulled: true })
573
- } else if (k.startsWith('bin:')) {
574
- const buf = bins.get(k)
575
- if (!buf) continue
576
- e.files.delete(rel)
577
- e.blobs.set(rel, { hash: k.slice(4), data: buf.toString('base64') })
578
- if (diff) notePresent(e, rel)
579
- history.record({ by: RELAY_BY, path: rel, kind: existed ? 'edited' : 'created', detail: `${buf.length} bytes`, pulled: true })
580
- } else {
581
- if (before === k) continue
582
- e.blobs.delete(rel)
583
- let y = e.files.get(rel)
584
- if (!y) { y = new Y.Text(); e.files.set(rel, y) }
585
- applyTextDiff(y, k)
586
- if (diff) notePresent(e, rel)
587
- history.record({ by: RELAY_BY, path: rel, kind: existed ? 'edited' : 'created', before: before ?? '', after: k, pulled: true })
588
- }
589
- written++
590
- }
591
- }, RELAY_ORIGIN)
592
- return written
593
- }
594
-
595
- /** The session's bytes of `rel` on the branch document (text or an inline binary), or null. */
596
- function sessionBuf (e, rel) {
597
- const t = e.files.get(rel)
598
- if (t) return Buffer.from(t.toString(), 'utf8')
599
- const b = e.blobs.get(rel)
600
- return b && typeof b.data === 'string' ? Buffer.from(b.data, 'base64') : null
601
- }
602
-
603
- async function readBlob (dir, sha) {
604
- const r = await runGit(dir, ['cat-file', 'blob', sha], { buffer: true })
605
- return r.ok ? r.out : null
606
- }
607
-
608
- /**
609
- * The held files, each looked at against its own base and theirs: one whose claim is gone (or that
610
- * raced an edit, or an older relay held for a clash) is combined with the session's copy now
611
- * (upstream.js combine) and lands, whoever's lines lost told; files an older relay held for failing
612
- * checks are let go of. Returns { landed, released: [paths] }.
613
- */
614
- async function settleHeld (room, key, { e, state, rec, dir, disk, bins, tooLarge, now, log }) {
615
- if (!state.held.length) return { landed: 0, released: [] }
616
- const union = await unionFor(e, dir, null)
617
- const writes = new Map()
618
- const released = []
619
- const keep = []
620
- const done = [] // { h, d, r, party, top, theirsName }
621
- for (const h of state.held) {
622
- const d = disk(h.path)
623
- if (d === undefined) { keep.push(h); continue } // a large stored file: a folder brings it in
624
- if (d === FOLLOWS_GIT) { released.push(h.path); continue } // a diff branch's file the session never changed: git's already
625
- if (h.why === 'checks') { released.push(h.path); closeOldClash(room, key, h, { how: 'let go of: files are no longer held for failing checks', now }); continue }
626
- if (h.why === 'claimed' && room.claimFor && room.claimFor(h.path, key)) { keep.push(h); continue }
627
- let base, theirs, party, top, theirsName
628
- if (h.kind === 'offline') {
629
- // An older relay's clash with a member's offline edits: theirs is what they brought back.
630
- base = h.baseBlob ? (await readBlob(dir, h.baseBlob))?.toString('utf8') ?? null : null
631
- theirs = h.otherDeleted ? null : h.otherBlob ? (await readBlob(dir, h.otherBlob))?.toString('utf8') : undefined
632
- if (theirs === undefined) { released.push(h.path); closeOldClash(room, key, h, { how: 'the session\'s version kept (the other is no longer kept)', now }); continue }
633
- const off = (h.parties && h.parties.offline) || {}
634
- party = sessionPartyOf(e, h.path)
635
- top = { name: off.name || 'a member', member: off.name || null, ts: off.ts || 0 }
636
- theirsName = `${off.name || 'a member'}'s offline edits`
637
- } else {
638
- if (!h.base || !h.theirs) { keep.push(h); continue }
639
- const [b, t] = await Promise.all([filesAt(dir, h.base, [h.path]), filesAt(dir, h.theirs, [h.path])])
640
- base = keyOf(b.get(h.path), h.path, { bins, tooLarge })
641
- theirs = keyOf(t.get(h.path), h.path, { bins, tooLarge })
642
- if (base === undefined || theirs === undefined) { keep.push(h); continue }
643
- const c = (await upstreamCommits(dir, h.base, h.theirs, h.path))[0] || null
644
- top = c ? { name: c.name, email: c.email, ts: c.ts, sha: c.sha, subject: c.subject, member: memberForAuthor(room, c) } : null
645
- party = sessionPartyOf(e, h.path)
646
- theirsName = rec.name
647
- }
648
- const oursNewer = ((party || {}).ts || 0) > ((top || {}).ts || 0) + 999 // a commit's whole second is its own
649
- const r = combine(base, d, theirs, { oursNewer, union: union(h.path) })
650
- done.push({ h, d, r, party, top, theirsName, both: d !== base && theirs !== base && d !== theirs })
651
- }
652
- // The session's version of a file whose lines lose is kept in the mirror too.
653
- for (const x of done) {
654
- if (!x.r.lost.some((l) => l.side === 'ours')) continue
655
- const buf = sessionBuf(e, x.h.path)
656
- x.blob = buf ? await storeBlob(dir, buf).catch(() => null) : null
657
- }
658
- // Each was combined against the session's copy as it was then: one edited since (the awaits let edits in) waits for the next look.
659
- for (const x of done) {
660
- if (disk(x.h.path) !== x.d) { keep.push(x.h); continue }
661
- if (x.r.key !== x.d) writes.set(x.h.path, x.r.key)
662
- released.push(x.h.path)
663
- if (x.blob) noteResolved(room, key, { path: x.h.path, kind: 'auto', base: x.h.base || null, theirs: x.h.theirs || null, oursBlob: x.blob, how: `merged by Quilt: ${x.theirsName}'s newer edit kept`, at: now })
664
- }
665
- const landed = applyWrites(room, e, writes, { bins, now })
666
- state.held = keep
667
- for (const x of done) {
668
- if (!released.includes(x.h.path)) continue
669
- const { h, r } = x
670
- if (h.why === 'clash' || h.kind === 'offline') closeOldClash(room, key, h, { how: 'merged by Quilt on its own', now })
671
- if (h.kind === 'offline' && h.upstreamMoved) state.held.push({ path: h.path, why: 'raced', base: h.upstreamMoved.base, theirs: h.upstreamMoved.theirs, head: h.upstreamMoved.theirs, since: now })
672
- if (h.kind === 'offline') {
673
- // The returning member's lines lost: they are told as the session's side would be.
674
- const lostTheirs = r.lost.filter((l) => l.side === 'theirs')
675
- if (lostTheirs.length && x.top && x.top.member && room.postChat) room.postChat({ by: RELAY_BY, to: x.top.member, kind: 'nudge', text: `Quilt merged your offline edits to ${h.path} on ${key}: where you and ${(x.party && x.party.name) || 'the session'} both changed the same lines, the newer edit stays. Your lines it replaced:\n${lostLines(lostTheirs)}` })
676
- tellAutoMerge(room, key, { path: h.path, lost: r.lost.filter((l) => l.side === 'ours'), deleted: r.deleted === 'ours' ? 'ours' : null, session: x.party, github: x.top, upstream: x.theirsName, now })
677
- } else {
678
- if (x.both) noteMerged(room, key, h.path, { session: x.party || null, github: x.top }, { now })
679
- tellAutoMerge(room, key, { path: h.path, lost: r.lost, deleted: r.deleted || null, session: x.party, github: x.top, upstream: x.theirsName, head: h.theirs, now })
680
- }
681
- }
682
- if (released.length) log(`[${room.name}] ${key}: ${released.join(', ')} merged and let go of`)
683
- return { landed, released }
684
- }
685
-
686
- const IGNORE_FILE = /(^|\/)\.(git|quilt|cowove)ignore$/
687
-
688
- /** A diff branch's entries measured from commit `from` are measured from `to` now (the session took what came between). */
689
- function moveFileBases (e, from, to) {
690
- const fb = e.fileBases
691
- const moved = [...fb.entries()].filter(([, c]) => c === from).map(([rel]) => rel)
692
- if (moved.length) e.doc.transact(() => { for (const rel of moved) fb.set(rel, to) }, RELAY_ORIGIN)
693
- }
694
-
695
- /**
696
- * A diff branch's entries that started from a commit older than the session's base `base` (a folder
697
- * behind GitHub edited a file git had, or the base moved on a folder's word): each takes what the
698
- * commits since changed in it, combined with the session's edit as a bring-in combines (the newer
699
- * edit wins lines both changed, whoever's lines lost is told), and is measured from `base` after.
700
- * An entry from a commit GitHub hasn't (a folder's own, not pushed) or not behind `base` is left as
701
- * it is: its own commit carries what it has. Returns how many files changed.
702
- */
703
- export async function liftEntries (room, key, { e, dir, base, now, log = () => {} }) {
704
- const fb = e.fileBases
705
- const groups = new Map()
706
- for (const rel of ownPaths(e)) {
707
- const c = fb.get(rel)
708
- if (typeof c === 'string' && c !== base && SHA.test(c)) groups.set(c, [...(groups.get(c) || []), rel])
709
- }
710
- let landed = 0
711
- for (const [from, rels] of groups) {
712
- if (!(await hasCommit(dir, from)) || !(await isAncestor(dir, from, base))) continue
713
- const all = await changedPaths(dir, from, base)
714
- const touched = rels.filter((rel) => all.has(rel))
715
- const bins = new Map(); const tooLarge = new Set()
716
- const left = new Set()
717
- if (touched.length) {
718
- const changes = new Map(touched.map((rel) => [rel, all.get(rel)]))
719
- const atFrom = await filesAt(dir, from, touched.filter((rel) => changes.get(rel) !== 'A'))
720
- const atBase = await filesAt(dir, base, touched.filter((rel) => changes.get(rel) !== 'D'))
721
- const baseMap = new Map(touched.map((rel) => [rel, changes.get(rel) === 'A' ? null : keyOf(atFrom.get(rel), rel, { bins, tooLarge })]))
722
- const theirs = new Map(touched.map((rel) => [rel, changes.get(rel) === 'D' ? null : keyOf(atBase.get(rel), rel, { bins, tooLarge })]))
723
- const disk = (rel) => docKey(e, rel)
724
- const tops = new Map(); const parties = new Map()
725
- for (const rel of touched) { tops.set(rel, (await upstreamCommits(dir, from, base, rel))[0] || null); parties.set(rel, sessionPartyOf(e, rel)) }
726
- const oursNewer = (rel) => ((parties.get(rel) || {}).ts || 0) > ((tops.get(rel) || {}).ts || 0) + 999
727
- const union = await unionFor(e, dir, base)
728
- const captured = new Map(touched.map((rel) => [rel, disk(rel)]))
729
- const plan = planCatchUp({ changes, base: baseMap, theirs, disk, published: publishedOf(room.doc), oursNewer, union })
730
- for (const c of plan.conflicts) left.add(c.path) // unreadable for the relay: kept as they are, measured from where they were
731
- const writes = new Map([...plan.writes].filter(([rel]) => !left.has(rel) && disk(rel) === captured.get(rel)))
732
- for (const rel of touched) if (disk(rel) !== captured.get(rel)) left.add(rel) // edited meanwhile: the next look
733
- landed += applyWrites(room, e, writes, { bins, now, base })
734
- for (const m of plan.merged) {
735
- if (left.has(m.path)) continue
736
- const top = tops.get(m.path)
737
- tellAutoMerge(room, key, { path: m.path, lost: m.lost, deleted: m.deleted || null, session: parties.get(m.path) || null, github: top ? { name: top.name, email: top.email, ts: top.ts, sha: top.sha, subject: top.subject, member: memberForAuthor(room, top) } : null, upstream: (upstreamsOf(room)[key] || {}).name || 'GitHub', head: base, now })
738
- }
739
- }
740
- const move = rels.filter((rel) => !left.has(rel) && fb.get(rel) === from)
741
- if (move.length) e.doc.transact(() => { for (const rel of move) fb.set(rel, base) }, RELAY_ORIGIN)
742
- if (touched.length) log(`[${room.name}] ${key}: brought ${plural(touched.length - left.size, 'file')} the session changed from ${from.slice(0, 7)} up to ${base.slice(0, 7)}`)
743
- }
744
- return landed
745
- }
746
-
747
- export const describeSync = (state) => !state ? '' : `base ${String(state.base || '').slice(0, 7) || 'none'}${state.pushed ? `, pushed ${String(state.pushed).slice(0, 7)}` : ''}${state.held.length ? `, held ${state.held.map((h) => h.path).join(', ')}` : ''}`
748
-
749
- export const validSha = (s) => typeof s === 'string' && SHA.test(s)
750
-
751
- /**
752
- * A failure-injection point (tests: room.cfg.crashAt names one). At it the relay "dies": nothing more
753
- * reaches its disk (meta or documents), and the operation stops, as a process killed there would.
754
- */
755
- export function crashPoint (room, name) {
756
- if (room.cfg && typeof room.cfg.atPoint === 'function') room.cfg.atPoint(name, room) // tests: something happens right here (an edit arrives)
757
- if (!room.cfg || room.cfg.crashAt !== name) return
758
- room.crashed = true
759
- if (room.store) room.store.frozen = true
760
- throw Object.assign(new Error(`crash injected at ${name}`), { injected: true })
761
- }
762
-
763
- /**
764
- * Where branch `key` stands with GitHub, for people and agents (addendum 16.13): { state, why?,
765
- * revision, pending, held, pr? }. `state`: 'paused' (the owner's switch, a deletion on GitHub, or a
766
- * sync not set up), 'permission-held' (GitHub refused the relay), 'offline' (GitHub out of reach),
767
- * 'pr-pending' (work going through a pull request: a
768
- * protected branch, or another session holds it), 'github-pending' (edits not on GitHub yet) or
769
- * 'synced'. 'github-pending' means files changed in the session since its last commit (nothing goes to GitHub until
770
- * someone commits). `revision`: the GitHub commit the session's content is acknowledged at.
771
- */
772
- export function syncStatus (room, key, { live = true } = {}) {
773
- const rec = upstreamsOf(room)[key]
774
- if (!rec || !rec.repo || !/^github\.com\//.test(rec.repo)) return null
775
- const st = (room.meta.sync && room.meta.sync[key]) || { pending: {}, held: [] }
776
- const out = { revision: st.base || rec.sha || null, pushed: st.pushed || null, pending: Object.keys(st.pending || {}).length, held: (st.held || []).filter((h) => h.why === 'clash' || h.why === 'checks').length }
777
- // Files that are GitHub's version, not edited in the session since (a loaded branch only: nothing is loaded to say so).
778
- const e = room.store && room.store.get ? room.store.get(key) : null
779
- const gh = e ? fromGithubOf(e) : []
780
- if (gh.length) { out.fromGithub = gh.length; out.fromGithubFiles = gh.slice(0, 10).map(({ path, sha, by }) => ({ path, sha, by })) }
781
- // Since when the session's content has been at this commit (first seen by the relay), for "at a1b2c3d since 14:02".
782
- if (out.revision) {
783
- if (!st.revisionSeen || st.revisionSeen.sha !== out.revision) st.revisionSeen = { sha: out.revision, at: Date.now() }
784
- out.revisionAt = st.revisionSeen.at
785
- }
786
- if (out.pending) out.pendingFiles = pendingFilesOf(e, st.pending)
787
- if (room.meta.githubSync === false) return { ...out, state: 'paused', why: 'the owner turned sync with GitHub off' }
788
- if (!live) return { ...out, state: 'paused', why: rec.github && rec.github.state && rec.github.state !== 'ok' ? 'nobody here has connected GitHub with write access to the repository yet' : 'sync with GitHub is not set up for this branch' }
789
- if (rec.gone) return { ...out, state: 'paused', why: rec.problem || `${rec.ref} was deleted on GitHub` }
790
- if (st.paused && st.paused.why === 'unreachable') return { ...out, state: 'offline', why: rec.problem || 'GitHub out of reach', since: st.paused.at }
791
- if (st.paused) return { ...out, state: 'permission-held', why: rec.problem || 'GitHub refused the relay' }
792
- if (st.protected && st.protected.pr) return { ...out, state: 'pr-pending', why: rec.problem || '', pr: st.protected.pr, live: st.protected.head || null }
793
- if (out.pending || st.op) return { ...out, state: 'github-pending' }
794
- return { ...out, state: 'synced' }
795
- }