heyquilt 0.3.138 → 0.3.141

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 (53) hide show
  1. package/RELEASES.md +10 -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-follow.js +18 -5
  21. package/src/session.js +58 -3526
  22. package/src/status.js +2 -58
  23. package/src/turn-sync.js +15 -2
  24. package/src/ui/changes.js +5 -14
  25. package/src/ui/chat.js +0 -1
  26. package/src/ui/common.js +0 -2
  27. package/src/ui/git-rule.js +2 -30
  28. package/src/ui/home.js +3 -76
  29. package/src/ui/session.js +34 -528
  30. package/src/ui/tree.js +2 -8
  31. package/src/ui/workspaces.js +2 -122
  32. package/src/ui-server.js +1 -38
  33. package/src/api/routes/github.js +0 -380
  34. package/src/branches.js +0 -194
  35. package/src/clash.js +0 -128
  36. package/src/commit-request.js +0 -59
  37. package/src/commit.js +0 -213
  38. package/src/git-hook.js +0 -48
  39. package/src/gitignore.js +0 -49
  40. package/src/gitstate.js +0 -1069
  41. package/src/relay-ci.js +0 -111
  42. package/src/relay-clash.js +0 -214
  43. package/src/relay-commit.js +0 -923
  44. package/src/relay-filekey.js +0 -46
  45. package/src/relay-git.js +0 -274
  46. package/src/relay-githttp.js +0 -166
  47. package/src/relay-github.js +0 -109
  48. package/src/relay-outbound.js +0 -1295
  49. package/src/relay-repo-moves.js +0 -43
  50. package/src/relay-sync.js +0 -795
  51. package/src/relay-upstream.js +0 -889
  52. package/src/sync-words.js +0 -42
  53. package/src/upstream.js +0 -192
@@ -1,1295 +0,0 @@
1
- // Session → GitHub through the relay's git mirror (relay-git.js). An edit on a synced branch
2
- // starts a batch: after 5 s of quiet (or 60 s after the first unpushed change) the session's
3
- // files that differ from the branch's base commit become one commit on the base, built with git
4
- // plumbing in the mirror (the tree is the base's tree with the pending files applied, as git add
5
- // would store them), authored by whoever changed the most lines, co-authored by the rest, and
6
- // pushed forward only. Files a member holds and paths git ignores never go in; conflict markers a
7
- // git merge left in a file are settled by Quilt first (settleMarkers: both sides' lines kept). Never a force-push: when the remote moved, inbound runs first
8
- // (relay-sync.js) and the commit is rebuilt on the new head.
9
- import fs from 'node:fs'
10
- import os from 'node:os'
11
- import path from 'node:path'
12
- import crypto from 'node:crypto'
13
- import { syncStateOf, runGit, GitFailed, gcMirror, deleteRemoteBranch } from './relay-git.js'
14
- import { githubWriter } from './github-commit.js'
15
- import { bringInBranch, keepRefs, keptObjects, crashPoint, remoteHead, filesAt, applyWrites } from './relay-sync.js'
16
- import { upstreamsOf, parseRepo, ignoreOf, hasMarkers, RELAY_ORIGIN } from './relay-upstream.js'
17
- import { docKey, isDiff, FOLLOWS_GIT, gitRuleFiles, removePath, notePresent, hasEntry } from './branch-view.js'
18
- import { gitBlobSha, slug, noreply, agentPrefix, othersInChange, othersEditing } from './github-commit.js'
19
- import { openPullOnPush, noteTaskRevision, agentCommitsOf } from './relay-commit.js'
20
- import { everyone, notePushedMerges, upstreamCommits, memberForAuthor, tellAutoMerge } from './relay-clash.js'
21
- import { notePublished, combine } from './upstream.js'
22
- import { looksBinary } from './fsutil.js'
23
- import { mergeAuto } from './merge3.js'
24
- import { unwrapAsRelay, decryptBlob } from './largefiles.js'
25
- import { lfsUpload } from './relay-lfs.js'
26
- import { isSafeRelPath, isIgnored, MAX_TEXT_BYTES, MAX_BINARY_BYTES } from './pathrules.js'
27
- import { RELAY_BY, isRelay } from './branches.js'
28
- import { validToken } from './relay-upstream.js'
29
- import { appCredentials } from './relay-github.js'
30
-
31
- /** With the relay's own timer on (cfg.syncAuto, off unless a relay is started with it): a batch commits after this long with no further change on the branch… */
32
- export const SYNC_QUIET_MS = 5 * 1000
33
- /** …or this long after its first unpushed change, whichever comes first. */
34
- export const SYNC_MAX_MS = 60 * 1000
35
- /** GitHub out of reach: the next try after this long, doubling up to OUTAGE_MAX_MS (plan §11). */
36
- export const OUTAGE_START_MS = 5 * 1000
37
- export const OUTAGE_MAX_MS = 5 * 60 * 1000
38
- /** A push waits for the repository's budget up to this long in place; longer, it is queued (addendum 16.11). */
39
- export const BUDGET_WAIT_MS = 15 * 1000
40
- /** A push the remote moved under is tried again this many times (inbound first each time) before the next batch window. */
41
- export const PUSH_RETRIES = 3
42
- export const COMMITTER = { name: 'quilt-by-heyquilt[bot]', email: 'quilt-by-heyquilt[bot]@users.noreply.github.com' }
43
- const LFS_POINTER = 'version https://git-lfs'
44
- const SPECIAL = new Set(['120000', '160000']) // a symlink, a submodule: kept as they are, never rewritten by the relay
45
-
46
- /** Where a protected branch's live work goes (plan §5.5). */
47
- export const liveBranchOf = (ref) => `quilt/live/${ref}`
48
-
49
- const plural = (k, w) => `${k} ${w}${k === 1 ? '' : 's'}`
50
-
51
- /** Whether branch `key` of the room syncs live to GitHub: the mirror is on, the owner hasn't switched it off, and the relay can write to the repository. */
52
- export function liveSyncOn (room, key) {
53
- if (!room.cfg || !room.cfg.gitSync || !room.mirrors || room.meta.githubSync === false || room.meta.syncStopped) return false
54
- const rec = upstreamsOf(room)[key]
55
- if (!rec || !rec.repo || !rec.ref) return false
56
- // Agents' policy "off" (addendum 16.7): an agent's own branch stays in the session; a person adopts its work (adoptBranch).
57
- if (agentCommitsOf(room) === 'off' && agentOwnerOf(room, rec.ref)) return false
58
- const repo = parseRepo(`https://${rec.repo}`)
59
- if (!repo || !repo.github) return false
60
- return validToken(room.meta.githubToken) || !!(rec.github && rec.github.state === 'ok')
61
- }
62
-
63
- /** The agent whose own branch `ref` is (quilt/<agent>/…), or null. */
64
- export function agentOwnerOf (room, ref) {
65
- return Object.values(room.meta.members || {}).find((m) => m && m.kind === 'agent' && m.name && String(ref).startsWith(agentPrefix(m.name)))?.name || null
66
- }
67
-
68
- /**
69
- * Whether agent `name` may commit straight to branch `key`'s GitHub branch through the relay (plan §2):
70
- * null when it may, else why not. "any": every branch. "branches" (the default): only its own
71
- * (quilt/<agent>/…); a commit elsewhere goes to a branch of its own with a pull request (relay-commit.js).
72
- * "off": nothing of its own reaches GitHub; a person adopts the work. Agents edit the session's files
73
- * on any branch as always: the policy is about what reaches GitHub, which only a commit does.
74
- */
75
- export function agentCommitRefusal (room, key, name) {
76
- const rec = upstreamsOf(room)[key]
77
- if (!rec || !rec.ref || !room.cfg || !room.cfg.gitSync || room.meta.githubSync === false) return null
78
- // The rule over the session (an org's, a team's, a workspace's) counts too: agentCommitsOf.
79
- const policy = agentCommitsOf(room)
80
- if (policy === 'any' || String(rec.ref).startsWith(agentPrefix(name))) return null
81
- if (!liveSyncOn(room, key)) return null
82
- return policy === 'off'
83
- ? `The session owner keeps agents' commits off GitHub: ask a person to commit (quilt_request_commit), or work on a branch of your own (quilt/${slug(name, 30)}/<topic>) for a person to adopt.`
84
- : `Agents commit only to branches of their own here: your commit goes to quilt/${slug(name, 30)}/<task>, with a pull request to ${rec.ref}.`
85
- }
86
-
87
- /**
88
- * The files `who`'s commit on branch `key` takes (quilt_git_commit): `files` when given, else every file
89
- * they changed on record on the branch (or the task's), as commitForMember does. { list } or { refused }.
90
- */
91
- export function ownFiles (room, key, { who, files = null, task = null }) {
92
- const e = room.branchDoc(key)
93
- const history = e.doc.getArray('history').toArray()
94
- const t = task ? room.doc.getMap('tasks').get(String(task)) : null
95
- if (task && !t) return { refused: `No task ${task} on the board.` }
96
- const clean = (l) => [...new Set((Array.isArray(l) ? l : []).filter((p) => typeof p === 'string').map((p) => p.replace(/^\.\//, '')).filter(isSafeRelPath))]
97
- let list = clean(files)
98
- if (Array.isArray(files) && files.length && !list.length) return { refused: 'None of those are files in the project (give paths relative to its top folder).' }
99
- if (!list.length) {
100
- for (const h of history) {
101
- if (!h || h.pulled || h.seed || typeof h.path !== 'string') continue
102
- if (t ? h.task && h.task.id === t.id : h.by === who) list.push(h.path)
103
- }
104
- if (t) list.push(...(Array.isArray(t.files) ? t.files : []))
105
- list = clean(list)
106
- }
107
- if (!list.length) return { refused: t ? `Task ${t.id} has no changed files on record: give them in files.` : 'Quilt has no changes of yours on record on this branch: give the files in files.' }
108
- return { list }
109
- }
110
-
111
- /** The author line of a member: an agent as <name> <slug@agents.noreply.heyquilt.com>, a person by their GitHub noreply when the relay knows it, else their name's noreply. */
112
- export function authorOf (room, name) {
113
- const m = everyone(room).find((x) => x && x.name === name)
114
- if (m && m.kind === 'agent') return { name, email: noreply(name) }
115
- const gh = m && m.github && m.github.login ? m.github : null
116
- if (gh) return { name, email: `${gh.id ? `${gh.id}+` : ''}${gh.login}@users.noreply.github.com` }
117
- return { name, email: `${slug(name, 40)}@people.noreply.heyquilt.com` }
118
- }
119
-
120
- /** "Session edits: a, b, c and 4 more" */
121
- export const editsMessage = (paths) => `Session edits: ${paths.slice(0, 6).join(', ')}${paths.length > 6 ? ` and ${paths.length - 6} more` : ''}`
122
-
123
- /**
124
- * Who changed what in `paths` since `since` (ms), from the branch's history: Map name -> lines,
125
- * and the one task every change was for (or null). Pulls from git and the relay's own writes are nobody's.
126
- */
127
- export function blameBatch (history, paths, { since = 0, sinceFor = null } = {}) {
128
- const want = new Set(paths)
129
- const lines = new Map()
130
- const tasks = new Set()
131
- let any = false
132
- for (const h of history || []) {
133
- if (!h || !want.has(h.path) || h.pulled || h.seed || !h.by || isRelay(h.by) || (h.ts || 0) < (sinceFor ? (sinceFor(h.path) || 0) : since)) continue
134
- any = true
135
- lines.set(h.by, (lines.get(h.by) || 0) + (h.added || 0) + (h.removed || 0) + (h.kind === 'deleted' || h.kind === 'created' ? 1 : 0))
136
- tasks.add(h.task && h.task.id ? `${h.task.id}\0${h.task.title || ''}` : '')
137
- }
138
- const ranked = [...lines].sort((a, b) => b[1] - a[1] || (a[0] < b[0] ? -1 : 1)).map(([n]) => n)
139
- const one = any && tasks.size === 1 && !tasks.has('') ? [...tasks][0].split('\0') : null
140
- return { author: ranked[0] || null, others: ranked.slice(1), task: one ? { id: one[0], title: one[1] } : null }
141
- }
142
-
143
- /**
144
- * The paths git ignores among `paths`, by the repository's own .gitignore files (every level,
145
- * git's own matching: `git check-ignore` over the ignore files as the session has them), plus
146
- * .quiltignore and Quilt's always-ignored list.
147
- */
148
- export async function ignoredPaths (dir, e, paths, base = null) {
149
- const out = new Set()
150
- if (!paths.length) return out
151
- // A diff branch (branch-view.js) leaves its unchanged rule files to git: the base commit's count too.
152
- const fromGit = isDiff(e) ? await gitRuleFiles(dir, base, e, /(^|\/)\.(git|quilt|cowove)ignore$/) : []
153
- const ig = ignoreOf(e, fromGit)
154
- for (const rel of paths) if (isIgnored(ig, rel)) out.add(rel)
155
- const left = paths.filter((p) => !out.has(p))
156
- if (!left.length) return out
157
- const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'quilt-ignore-'))
158
- try {
159
- let any = false
160
- for (const [rel, t] of [...[...e.files].map(([r, y]) => [r, y.toString()]), ...fromGit]) {
161
- if (rel !== '.gitignore' && !rel.endsWith('/.gitignore')) continue
162
- fs.mkdirSync(path.dirname(path.join(tmp, rel)), { recursive: true })
163
- fs.writeFileSync(path.join(tmp, rel), t)
164
- any = true
165
- }
166
- if (!any) return out
167
- const r = await runGit(tmp, ['check-ignore', '-z', '--stdin', '--no-index'], { input: left.join('\0') + '\0', env: { GIT_DIR: dir, GIT_WORK_TREE: tmp } })
168
- for (const rel of (r.out || '').split('\0')) if (rel) out.add(rel)
169
- } finally { fs.rmSync(tmp, { recursive: true, force: true }) }
170
- return out
171
- }
172
-
173
- /** The `filter` attribute of each path (from the repository's .gitattributes as the session has them): Map path -> name, for those with one. */
174
- async function filterAttrs (dir, e, paths, base = null) {
175
- const out = new Map()
176
- if (!paths.length) return out
177
- const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'quilt-attr-'))
178
- try {
179
- let any = false
180
- for (const [rel, t] of await attributesOf(dir, e, base)) {
181
- fs.mkdirSync(path.dirname(path.join(tmp, rel)), { recursive: true })
182
- fs.writeFileSync(path.join(tmp, rel), t)
183
- any = true
184
- }
185
- if (!any) return out
186
- const r = await runGit(tmp, ['check-attr', '-z', '--stdin', 'filter'], { input: paths.join('\0') + '\0', env: { GIT_DIR: dir, GIT_WORK_TREE: tmp, GIT_INDEX_FILE: path.join(tmp, '.index') } })
187
- const f = (r.out || '').split('\0')
188
- for (let i = 0; i + 2 < f.length; i += 3) if (f[i + 2] !== 'unspecified' && f[i + 2] !== 'unset' && f[i + 2] !== 'set') out.set(f[i], f[i + 2])
189
- } finally { fs.rmSync(tmp, { recursive: true, force: true }) }
190
- return out
191
- }
192
-
193
- /** The branch's .gitattributes files with their text: the session's, and a diff branch's unchanged ones from git at `base`. */
194
- async function attributesOf (dir, e, base) {
195
- const out = [...e.files].filter(([rel]) => rel === '.gitattributes' || rel.endsWith('/.gitattributes')).map(([rel, t]) => [rel, t.toString()])
196
- if (isDiff(e)) out.push(...await gitRuleFiles(dir, base, e, /(^|\/)\.gitattributes$/))
197
- return out
198
- }
199
-
200
- /** The base commit's tree: Map path -> { mode, sha }. */
201
- async function treeOf (dir, sha) {
202
- const r = await runGit(dir, ['ls-tree', '-r', '-z', sha])
203
- if (!r.ok) throw new GitFailed(`listing ${sha.slice(0, 7)}`, r.err)
204
- const out = new Map()
205
- for (const entry of r.out.split('\0')) {
206
- if (!entry) continue
207
- const tab = entry.indexOf('\t')
208
- const [mode, , oid] = entry.slice(0, tab).split(' ')
209
- out.set(entry.slice(tab + 1), { mode, sha: oid })
210
- }
211
- return out
212
- }
213
-
214
- /** The session's bytes for a path on the branch document, or null (not there), or undefined (a large stored file the relay can't read). */
215
- function sessionBytes (e, rel) {
216
- const t = e.files.get(rel)
217
- if (t) return Buffer.from(t.toString(), 'utf8')
218
- const b = e.blobs.get(rel)
219
- if (b && b.stored) return undefined
220
- if (b) return Buffer.from(b.data || '', 'base64')
221
- // A diff branch's file the session never changed is git's (branch-view.js): nothing of the session's to read or send.
222
- if (isDiff(e) && !e.gone.has(rel)) return undefined
223
- return null
224
- }
225
-
226
- /** GitHub refuses a file over this in a plain commit; beyond it, Git LFS (plan §10). */
227
- export const GITHUB_MAX_BYTES = 100 * 1024 * 1024
228
-
229
- /**
230
- * A large file kept encrypted in storage, as the relay can read it (plan §10): through its own wrap of
231
- * the file key (the branch syncs with GitHub and the owner agreed), fetched and decrypted, checked
232
- * against the hash the session keeps. Resolves the bytes, or undefined when the relay has no wrap of
233
- * the key (the file stays end-to-end encrypted). Throws when the stored file can't be read or is wrong.
234
- */
235
- export async function storedBytes (room, e, rel) {
236
- const b = e.blobs.get(rel)
237
- if (!b || !b.stored || !room.relayFileKey || !e.relayKeys) return undefined
238
- const w = e.relayKeys.get(`${b.stored.key}:${room.relayFileKey.id}`)
239
- const fileKey = w ? unwrapAsRelay(w, room.relayFileKey.privateKey) : null
240
- if (!fileKey) return undefined
241
- const store = room.blobStore
242
- if (!store) throw new Error('this relay keeps no stored files')
243
- let sealed
244
- if (typeof store.file === 'function') sealed = fs.readFileSync(store.file(room.name, b.stored.id))
245
- else {
246
- const t = await store.downloadTarget(room.name, b.stored.id)
247
- const res = await fetch(t.url)
248
- if (!res.ok) throw new Error(`storage answered ${res.status}`)
249
- sealed = Buffer.from(await res.arrayBuffer())
250
- }
251
- const plain = decryptBlob(sealed, fileKey)
252
- if (crypto.createHash('sha1').update(plain).digest('hex') !== b.hash) throw new Error('the stored file isn\'t the one the session has')
253
- return plain
254
- }
255
-
256
- /**
257
- * A text with git's conflict markers, settled the way Quilt merges: each <<<<<<< … ======= … >>>>>>>
258
- * block becomes both sides' lines (shared leading and trailing lines once); with the base recorded
259
- * (||||||| in diff3 style), lines only one side changed are kept and lines both changed are the first
260
- * side's. Returns the text, or null when the markers aren't whole blocks.
261
- */
262
- export function settleMarkers (text) {
263
- const lines = text.split('\n')
264
- const out = []
265
- const mark = (l, c) => l === c.repeat(7) || l.startsWith(`${c.repeat(7)} `)
266
- for (let i = 0; i < lines.length; i++) {
267
- if (!mark(lines[i], '<')) { if (mark(lines[i], '>')) return null; out.push(lines[i]); continue }
268
- const a = []; let o = null; const b = []
269
- let part = a
270
- let j = i + 1
271
- for (; j < lines.length; j++) {
272
- const l = lines[j]
273
- if (mark(l, '<')) return null
274
- if (mark(l, '|') && part === a) { o = []; part = o; continue }
275
- if (l === '=======' && part !== b) { part = b; continue }
276
- if (mark(l, '>')) break
277
- part.push(l)
278
- }
279
- if (j >= lines.length || part !== b) return null
280
- const nl = (ls) => ls.length ? `${ls.join('\n')}\n` : ''
281
- const both = !a.length ? b : !b.length ? a : mergeAuto(o ? nl(o) : '', nl(a), nl(b), { oursNewer: true }).text.split('\n').slice(0, -1)
282
- out.push(...both)
283
- i = j
284
- }
285
- return out.join('\n')
286
- }
287
-
288
- /**
289
- * Builds the commit for branch `key` on `base`: the base's tree with every session file that
290
- * differs applied, and the deletions the session made since the base. Resolves
291
- * { sha, tree, changed: [paths], skipped: { ignored, markers, held, claimed, special, large } } or
292
- * { sha: null, skipped } when nothing would change. `only`: limit to these paths (quilt_commit).
293
- */
294
- export async function buildCommit (room, key, { dir, base, message = '', by = '', only = null, now = Date.now(), upstream = null, own = null, lfs = null }) {
295
- const e = room.branchDoc(key)
296
- const state = syncStateOf(room, key)
297
- const baseTree = await treeOf(dir, base)
298
- const skipped = { ignored: [], markers: [], held: [], claimed: [], special: [], large: [], encrypted: [], unreadable: [], lfs: [] }
299
- const held = new Set(state.held.map((h) => h.path))
300
- const history = e.doc.getArray('history').toArray()
301
- const since = state.baseAt || 0
302
- // What the session has: every file on the branch document.
303
- const candidates = new Map() // path -> Buffer | null (delete)
304
- const paths = new Set([...e.files.keys(), ...e.blobs.keys()])
305
- // Large files kept encrypted in storage (plan §10): read by the relay when it may (its wrap of the key), else left out with a notice.
306
- const stored = new Map()
307
- const unreadable = new Set()
308
- for (const rel of paths) {
309
- const b = e.blobs.get(rel)
310
- if (!b || !b.stored || !isSafeRelPath(rel) || (only && !only.includes(rel))) continue
311
- let buf
312
- try { buf = await storedBytes(room, e, rel) } catch (err) { (room.log || (() => {}))(`[${room.name}] ${key}: could not read the stored ${rel}: ${err.message}`); unreadable.add(rel) }
313
- if (buf) stored.set(rel, buf)
314
- }
315
- for (const rel of paths) {
316
- if (!isSafeRelPath(rel) || (only && !only.includes(rel))) continue
317
- const buf = sessionBytes(e, rel) ?? stored.get(rel)
318
- if (buf === undefined) { (unreadable.has(rel) ? skipped.unreadable : skipped.encrypted).push(rel); continue } // stored, and the relay may not (or could not) read it
319
- const was = baseTree.get(rel)
320
- if (was && was.sha === gitBlobSha(buf)) continue
321
- candidates.set(rel, buf)
322
- }
323
- // On a live branch whose target moved on (addendum 16.5): a file the session has as the target has it, and the target
324
- // changed since the live branch started, is the target's change, already there: not the session's to send again.
325
- let target = null
326
- let fork = null
327
- if (upstream) { target = await treeOf(dir, upstream.target); fork = await treeOf(dir, upstream.fork) }
328
- const theirs = (rel, buf) => {
329
- if (!target) return false
330
- const t = target.get(rel)
331
- const f = fork.get(rel)
332
- if (buf === null) return !t && !!f
333
- return !!t && t.sha === gitBlobSha(buf) && (!f || f.sha !== t.sha)
334
- }
335
- for (const [rel, buf] of [...candidates]) if (theirs(rel, buf)) candidates.delete(rel)
336
- // A diff branch says what it deleted (branch-view.js): every deletion of a file the base has goes in.
337
- if (isDiff(e)) {
338
- for (const rel of e.gone.keys()) {
339
- if (!isSafeRelPath(rel) || !baseTree.has(rel) || (only && !only.includes(rel)) || theirs(rel, null)) continue
340
- candidates.set(rel, null)
341
- }
342
- }
343
- // Deletions the session made since the base (its history says so; a file Quilt never synced is not a deletion).
344
- for (const h of history) {
345
- if (!h || h.kind !== 'deleted' || h.pulled || h.seed || (h.ts || 0) < since || !baseTree.has(h.path) || paths.has(h.path)) continue
346
- if (isDiff(e) && !e.gone.has(h.path)) continue // a diff branch: back since, or git's again
347
- if (only && !only.includes(h.path)) continue
348
- if (theirs(h.path, null)) continue
349
- candidates.set(h.path, null)
350
- }
351
- const ignored = await ignoredPaths(dir, e, [...candidates.keys()], base)
352
- const changes = new Map()
353
- const lfsPaths = new Set()
354
- const filters = await filterAttrs(dir, e, [...candidates.keys()], base)
355
- for (const [rel, buf] of candidates) {
356
- const was = baseTree.get(rel)
357
- // git's rule: a file the repository already tracks is committed even when a .gitignore matches it now (addendum 16.12).
358
- if (ignored.has(rel) && !was) { skipped.ignored.push(rel); continue }
359
- // The relay never runs a repository's filters (addendum 16.12): text/eol only; LFS through its batch API (§10), others refused.
360
- if (filters.get(rel) && filters.get(rel) !== 'lfs') { (skipped.filtered = skipped.filtered || []).push(rel); continue }
361
- const viaLfs = filters.get(rel) === 'lfs' && buf !== null
362
- if (held.has(rel)) { skipped.held.push(rel); continue }
363
- const c = room.claimFor ? room.claimFor(rel, key) : null
364
- if (c && c.by !== by) { skipped.claimed.push(rel); continue } // a member's own held files go when they commit now
365
- if (was && SPECIAL.has(was.mode)) { skipped.special.push(rel); continue }
366
- // Stored large files (read by the relay) go up to GitHub's own limit; past it, or through filter=lfs, Git LFS.
367
- const limit = viaLfs ? Infinity : stored.has(rel) ? GITHUB_MAX_BYTES : (buf && buf.includes(0) ? MAX_BINARY_BYTES : MAX_TEXT_BYTES)
368
- if (buf && buf.length > limit) { (stored.has(rel) ? skipped.lfs : skipped.large).push(rel); continue }
369
- if (buf && !buf.includes(0) && hasMarkers(buf.toString('utf8'))) {
370
- // Conflict markers a git merge left: Quilt settles them in the session (both sides' lines kept) and commits that.
371
- const settled = settleMarkers(buf.toString('utf8'))
372
- if (settled === null) { skipped.markers.push(rel); continue }
373
- applyWrites(room, e, new Map([[rel, settled]]), { bins: new Map(), now })
374
- ;(skipped.settled = skipped.settled || []).push(rel)
375
- changes.set(rel, Buffer.from(settled, 'utf8'))
376
- continue
377
- }
378
- if (viaLfs) {
379
- // Through Git LFS: the bytes to GitHub's LFS store, the pointer in the commit.
380
- if (!lfs || !lfs.url) { skipped.lfs.push(rel); continue }
381
- try { changes.set(rel, Buffer.from(await lfsUpload({ remoteUrl: lfs.url, token: lfs.token, bytes: buf, fetch: lfs.fetch }), 'utf8')); lfsPaths.add(rel) } catch (err) { (room.log || (() => {}))(`[${room.name}] ${key}: ${rel} through Git LFS: ${err.message}`); skipped.lfs.push(rel) }
382
- continue
383
- }
384
- if (was) {
385
- const r = await runGit(dir, ['cat-file', '-p', was.sha], { buffer: true })
386
- if (r.ok && r.out.subarray(0, 24).toString() .startsWith(LFS_POINTER)) { skipped.special.push(rel); continue }
387
- }
388
- changes.set(rel, buf)
389
- }
390
- // A case-only rename (README.md → Readme.md) goes as two commits, the deletion first, so a case-insensitive checkout never loses the file.
391
- const lower = new Map()
392
- for (const [rel, buf] of changes) if (buf === null) lower.set(rel.toLowerCase(), rel)
393
- skipped.renameWait = []
394
- for (const [rel, buf] of changes) if (buf !== null && !baseTree.has(rel) && lower.has(rel.toLowerCase()) && lower.get(rel.toLowerCase()) !== rel) skipped.renameWait.push(rel)
395
- for (const rel of skipped.renameWait) changes.delete(rel)
396
- if (!changes.size) return { sha: null, tree: null, changed: [], skipped }
397
- // A member's own commit (quilt_git_commit): others' changes in these files, not in git yet, go in only with with_others (they are co-authors then).
398
- let ownOthers = null
399
- if (own) {
400
- const others = new Map()
401
- for (const [rel, buf] of changes) {
402
- const entries = history.filter((h) => h && h.path === rel)
403
- const was = baseTree.get(rel)
404
- const binary = (buf && buf.includes(0)) || e.blobs.get(rel)
405
- let names
406
- if (!binary) {
407
- const before = was ? (await runGit(dir, ['cat-file', '-p', was.sha], { buffer: true })).out : null
408
- names = othersInChange({ before: before ? before.toString('utf8') : '', after: buf ? buf.toString('utf8') : '', entries, me: own.who })
409
- } else names = othersEditing([rel], history, { me: own.who, since: (state.pushedAt || {})[rel] || state.baseAt || 0 }).get(rel) || []
410
- names = names.filter((n) => !isRelay(n))
411
- if (names.length) others.set(rel, names)
412
- }
413
- if (others.size && !own.withOthers) return { sha: null, tree: null, changed: [], skipped, others }
414
- ownOthers = [...new Set([...others.values()].flat())]
415
- }
416
- // The tree: the base's, with the changes applied through a temporary index and an empty work tree holding the
417
- // repository's .gitattributes files, as git add stores files (text/eol rules honoured).
418
- const scratch = fs.mkdtempSync(path.join(os.tmpdir(), 'quilt-index-'))
419
- const index = path.join(scratch, 'index')
420
- const worktree = path.join(scratch, 'tree')
421
- fs.mkdirSync(worktree)
422
- for (const [rel, t] of await attributesOf(dir, e, base)) {
423
- fs.mkdirSync(path.dirname(path.join(worktree, rel)), { recursive: true })
424
- fs.writeFileSync(path.join(worktree, rel), t)
425
- }
426
- // Run from the work tree with GIT_DIR at the mirror: only then does git apply the attributes to what it hashes.
427
- const env = { GIT_DIR: dir, GIT_INDEX_FILE: index, GIT_WORK_TREE: worktree }
428
- const wt = worktree
429
- try {
430
- const read = await runGit(wt, ['read-tree', base], { env })
431
- if (!read.ok) throw new GitFailed('reading the base tree', read.err)
432
- for (const [rel, buf] of changes) {
433
- if (buf === null) {
434
- const rm = await runGit(wt, ['update-index', '--force-remove', '--', rel], { env })
435
- if (!rm.ok) throw new GitFailed(`removing ${rel}`, rm.err)
436
- continue
437
- }
438
- const blob = await runGit(wt, ['hash-object', '-w', '--stdin', ...(lfsPaths.has(rel) ? ['--no-filters'] : ['--path', rel])], { input: buf, env })
439
- if (!blob.ok) throw new GitFailed(`storing ${rel}`, blob.err)
440
- const mode = (baseTree.get(rel) || {}).mode || (e.modes && e.modes.get && e.modes.get(rel) === 'x' ? '100755' : '100644')
441
- const add = await runGit(wt, ['update-index', '--add', '--cacheinfo', `${mode},${blob.out.trim()},${rel}`], { env })
442
- if (!add.ok) throw new GitFailed(`adding ${rel}`, add.err)
443
- }
444
- const tree = await runGit(wt, ['write-tree'], { env })
445
- if (!tree.ok) throw new GitFailed('writing the tree', tree.err)
446
- const treeSha = tree.out.trim()
447
- const baseTreeSha = (await runGit(dir, ['rev-parse', `${base}^{tree}`])).out.trim()
448
- if (treeSha === baseTreeSha) return { sha: null, tree: treeSha, changed: [], skipped }
449
- const changed = [...changes.keys()].sort()
450
- const pushedAt = state.pushedAt || {}
451
- const who = blameBatch(history, changed, { sinceFor: (rel) => pushedAt[rel] || 0 })
452
- const authorName = by || who.author || RELAY_BY
453
- const author = authorOf(room, authorName)
454
- // A member's own commit names exactly the others whose lines are in it; a batch, everyone who changed its files since the last push.
455
- const coAuthors = (ownOthers || [who.author, ...who.others]).filter((n) => n && n !== authorName && !isRelay(n)).map((n) => authorOf(room, n))
456
- const subject = String(message || '').trim() || (who.task && who.task.title ? who.task.title : editsMessage(changed))
457
- const body = [subject, ...(coAuthors.length ? ['', ...coAuthors.map((a) => `Co-authored-by: ${a.name} <${a.email}>`)] : [])].join('\n')
458
- const ts = Math.floor(now / 1000)
459
- const committer = (room.cfg && room.cfg.committer) || COMMITTER
460
- const c = await runGit(wt, ['commit-tree', treeSha, '-p', base, '-F', '-'], { input: body + '\n', env: { ...env, GIT_AUTHOR_NAME: author.name, GIT_AUTHOR_EMAIL: author.email, GIT_AUTHOR_DATE: `${ts} +0000`, GIT_COMMITTER_NAME: committer.name, GIT_COMMITTER_EMAIL: committer.email, GIT_COMMITTER_DATE: `${ts} +0000` } })
461
- if (!c.ok) throw new GitFailed('making the commit', c.err)
462
- return { sha: c.out.trim(), tree: treeSha, changed, skipped, author: authorName, coAuthors: coAuthors.map((a) => a.name), subject, contents: changes }
463
- } finally { fs.rmSync(scratch, { recursive: true, force: true }) }
464
- }
465
-
466
- /** People whose folder on branch `key` has local commits ahead of GitHub (and not behind): their names. */
467
- export function foldersAhead (room, key) {
468
- const out = []
469
- if (!room.awareness || !room.conns) return out
470
- const states = room.awareness.getStates()
471
- for (const [ws, ids] of room.conns) {
472
- if (ws.branch !== key) continue
473
- const a = room.access && room.access.get(ws)
474
- if (a && a.kind === 'agent') continue
475
- for (const id of ids) {
476
- const g = states.get(id)?.git
477
- const u = g && g.upstream
478
- if (u && u.ahead > 0 && !(u.behind > 0) && !g.held) out.push(room.names ? room.names.get(ws) : '')
479
- }
480
- }
481
- return out
482
- }
483
-
484
- /** Whether the mirror has commit `sha`, fetching `refs` (the branch, the one it was cut from) until it does. Throws GitFailed for sign-in or network trouble. */
485
- /**
486
- * The relay put branch `key`'s session at GitHub commit `sha` (a push, a replay, a recovered push): the branch
487
- * record says so, the one in the room's meta included when a folder's presence replaced the record meanwhile
488
- * (noteFolders), so folders following the relay (session.js memberAt) see it.
489
- */
490
- function relayAt (room, key, rec, sha) {
491
- const cur = upstreamsOf(room)[key]
492
- for (const r of new Set([rec, ...(cur && cur.repo === rec.repo && cur.ref === rec.ref ? [cur] : [])])) { r.sha = sha; r.head = sha; r.relayHead = sha }
493
- }
494
-
495
- /** How long a push waits for an older app's folder bringing GitHub's commits in itself to say it has them (bringInFirst). */
496
- export const FOLDER_BRINGS_WAIT_MS = 10 * 1000
497
-
498
- /**
499
- * Brings GitHub's head into the session before a push, holding the branch's queue (relay-sync.js bringInBranch).
500
- * An older app's folder on the branch brings them in itself ('folder-brings'): it is waited for, up to
501
- * FOLDER_BRINGS_WAIT_MS, as its word that it has them moves the base.
502
- */
503
- async function bringInFirst (room, key, log) {
504
- let inb = await bringInBranch(room, key, { log, now: () => Date.now(), inQueue: true })
505
- for (const until = Date.now() + FOLDER_BRINGS_WAIT_MS; inb.state === 'folder-brings' && Date.now() < until && !room.destroyed;) {
506
- await new Promise((resolve) => setTimeout(resolve, 500))
507
- inb = await bringInBranch(room, key, { log, now: () => Date.now(), inQueue: true })
508
- }
509
- return inb
510
- }
511
-
512
- async function haveCommit (room, dir, sha, refs, token) {
513
- if ((await runGit(dir, ['cat-file', '-e', `${sha}^{commit}`])).ok) return true
514
- for (const ref of [...new Set(refs.filter(Boolean))]) {
515
- await room.mirrors.fetch(dir, ref, { token })
516
- if ((await runGit(dir, ['cat-file', '-e', `${sha}^{commit}`])).ok) return true
517
- }
518
- return false
519
- }
520
-
521
- /** The session's content of `rel` on `key`, as git's blob id (null: not there; undefined: a large stored file). */
522
- function sessionId (room, key, rel) {
523
- const b = sessionBytes(room.branchDoc(key), rel)
524
- return b === undefined ? undefined : b === null ? null : gitBlobSha(b)
525
- }
526
-
527
- /**
528
- * A push the relay had started when it stopped (state.op, saved before the push): recognised by the
529
- * commit being on GitHub, at or behind its head, even when GitHub moved on since (addendum 16.1).
530
- * Accepted: the files that went in, unchanged since, are no longer pending, and the base moves to it
531
- * when GitHub is still there (or inbound brings the rest). Not accepted: forgotten; the files are still
532
- * pending and go with the next push. Never a second copy of the same commit. Resolves what it found.
533
- */
534
- export async function recoverOp (room, key, { dir, token }) {
535
- const state = syncStateOf(room, key)
536
- const op = state.op
537
- if (!op || op.kind !== 'push') return null
538
- const rec = upstreamsOf(room)[key]
539
- let head = null
540
- try { head = (await room.mirrors.fetch(dir, op.branch, { token })).sha } catch { return null }
541
- const has = head && (head === op.candidate || (await runGit(dir, ['merge-base', '--is-ancestor', op.candidate, head])).ok)
542
- delete state.op
543
- if (!has) { room.saveMeta(); return { accepted: false } }
544
- state.pushed = op.candidate
545
- if (op.branch === rec.ref) {
546
- if (head === op.candidate) { state.base = op.candidate; relayAt(room, key, rec, op.candidate) }
547
- for (const [rel, id] of Object.entries(op.files || {})) if (sessionId(room, key, rel) === id) delete state.pending[rel]
548
- } else if (state.protected) state.protected.head = head === op.candidate ? op.candidate : state.protected.head
549
- room.saveMeta()
550
- room.log && room.log(`[${room.name}] ${key}: ${op.candidate.slice(0, 7)} was pushed before the relay stopped${head !== op.candidate ? ` (GitHub has moved on to ${head.slice(0, 7)} since)` : ''}`)
551
- return { accepted: true, head }
552
- }
553
-
554
- /** The outbound side of a room: batches per branch, and the push. */
555
- export function outboundFor (room) {
556
- const timers = new Map() // key -> { quiet, max, first }
557
- const retryTimers = new Map() // key -> the next try after GitHub was out of reach
558
- const log = room.log || (() => {})
559
- const cfg = room.cfg || {}
560
- const quietMs = Number.isFinite(cfg.syncQuietMs) ? cfg.syncQuietMs : SYNC_QUIET_MS
561
- const maxMs = Number.isFinite(cfg.syncMaxMs) ? cfg.syncMaxMs : SYNC_MAX_MS
562
-
563
- const clear = (key) => { const t = timers.get(key); if (t) { clearTimeout(t.quiet); clearTimeout(t.max); timers.delete(key) } }
564
-
565
- /** A change on `key` (not the relay's own): pending, and the batch window starts or restarts. */
566
- const note = (key, rel, { now = Date.now() } = {}) => {
567
- if (!liveSyncOn(room, key) || !isSafeRelPath(rel)) return false
568
- const state = syncStateOf(room, key)
569
- const fresh = !state.pending[rel]
570
- state.pending[rel] = now
571
- if (fresh) tellSoon() // the branch's count of files not committed changed: the apps and agents hear it
572
- schedule(key, { now })
573
- return true
574
- }
575
-
576
- // The branch list goes out at most twice a second for new uncommitted files (an edit burst is one update).
577
- let tellTimer = null
578
- const tellSoon = () => {
579
- if (tellTimer) return
580
- tellTimer = setTimeout(() => { tellTimer = null; room.saveMeta(); if (room.broadcastBranches) room.broadcastBranches() }, 500)
581
- tellTimer.unref?.()
582
- }
583
-
584
- /** The batch window for `key`: quietMs after the last change, or maxMs after the first pending one. */
585
- const schedule = (key, { now = Date.now(), at = 0 } = {}) => {
586
- const asked = Array.isArray(syncStateOf(room, key).asked) && syncStateOf(room, key).asked.length
587
- // A commit someone asked for, waiting for the push budget: it goes at `at` (the relay's own batches only with its timer on).
588
- if (at && (cfg.syncAuto === true || asked)) { const t = timers.get(key) || {}; clearTimeout(t.quiet); clearTimeout(t.max); t.quiet = setTimeout(() => (cfg.syncAuto === true ? flush(key, { auto: true }) : runAsked(key)).catch(() => {}), Math.max(0, at - now)); t.quiet.unref?.(); timers.set(key, t); return }
589
- if (cfg.syncAuto !== true) return
590
- const state = syncStateOf(room, key)
591
- const first = Math.min(...Object.values(state.pending), now)
592
- const t = timers.get(key) || { first }
593
- clearTimeout(t.quiet)
594
- t.quiet = setTimeout(() => flush(key, { auto: true }).catch(() => {}), quietMs)
595
- t.quiet.unref?.()
596
- if (!t.max) { t.max = setTimeout(() => flush(key, { auto: true }).catch(() => {}), Math.max(0, maxMs - (now - first))); t.max.unref?.() }
597
- timers.set(key, t)
598
- }
599
-
600
- /**
601
- * Commits and pushes what is pending on `key` now (quilt_commit, the batch window, a release):
602
- * { state: 'pushed', sha, changed, skipped } · 'nothing' · 'moved' (the remote kept moving) ·
603
- * 'protected' · 'paused' (sign-in or the repository refused: told) · 'off' · 'error'.
604
- */
605
- /**
606
- * A commit asked for (quilt_git_commit, a person adopting work): made now. One GitHub couldn't take
607
- * (out of reach, or refusing the relay) is kept and made when it answers, in the order asked.
608
- */
609
- const flush = async (key, o = {}) => {
610
- const state = syncStateOf(room, key)
611
- let ask = o.message && !o.auto ? { message: o.message, by: o.by || '', only: o.only || null, own: o.own || null, at: Date.now() } : null
612
- if (ask) {
613
- const list = Array.isArray(state.asked) ? state.asked : []
614
- const same = list.find((a) => a.message === ask.message && a.by === ask.by && JSON.stringify(a.only) === JSON.stringify(ask.only))
615
- if (same) ask = same // asked again (or made from the list): it keeps its place
616
- else state.asked = [...list, ask].slice(-20)
617
- }
618
- let r
619
- try { r = await flushNow(key, o) } finally {
620
- if (ask && !(r && ((r.state === 'paused' && r.why !== 'lease') || r.state === 'queued'))) {
621
- state.asked = (state.asked || []).filter((a) => a !== ask)
622
- if (!state.asked.length) delete state.asked
623
- room.saveMeta()
624
- }
625
- }
626
- return r
627
- }
628
-
629
- const flushNow = async (key, { message = '', by = '', only = null, auto = false, own = null } = {}) => {
630
- clear(key)
631
- if (auto && cfg.syncAuto !== true) return { state: 'off' } // the relay's own timer is off: commits happen when someone asks
632
- if (!liveSyncOn(room, key)) return { state: 'off' }
633
- const recs = upstreamsOf(room)
634
- const rec = recs[key]
635
- const state = syncStateOf(room, key)
636
- if (!state.base && rec.sha) state.base = rec.sha
637
- if (!state.base) return { state: 'off' }
638
- if (state.paused) {
639
- const until = Number.isFinite(state.paused.until) ? state.paused.until : state.paused.at + 5 * 60 * 1000
640
- if (Date.now() < until) return { state: 'paused', why: state.paused.why, retryIn: until - Date.now() }
641
- }
642
- // §6: a person's folder on the branch has commits ahead of GitHub: theirs go first (their message wins), for up to 30 s.
643
- if (!message && !only) {
644
- const ahead = foldersAhead(room, key)
645
- if (ahead.length) {
646
- if (!state.aheadSince) state.aheadSince = Date.now()
647
- if (Date.now() - state.aheadSince < (Number.isFinite(cfg.folderAheadWaitMs) ? cfg.folderAheadWaitMs : FOLDER_AHEAD_WAIT_MS)) { schedule(key); return { state: 'waiting', for: ahead } }
648
- } else delete state.aheadSince
649
- }
650
- // §5.4: a branch deleted on GitHub comes back with the next commit, unless the owner turned that off.
651
- if (rec.gone && (room.meta.recreateDeleted !== true || rec.merged)) return { state: 'deleted', why: rec.problem || `${rec.ref} was deleted on GitHub` }
652
- const repo = parseRepo(`https://${rec.repo}`)
653
- let dir, token
654
- try {
655
- // Who the commit is for: their GitHub permissions count when the org requires GitHub (plan §2).
656
- const actor = by ? everyone(room).find((m) => m.name === by) : null
657
- const t = room.gitTokenState ? await room.gitTokenState(repo, { access: 'write', actingFor: actor && /^(person|agent):/.test(actor.id) ? actor.id : '' }) : { token: await room.gitToken(repo, { access: 'write' }) }
658
- if (!t.token && (t.state === 'not-allowed' || t.require)) {
659
- rec.problem = t.state === 'not-allowed' ? `${t.why || `this workspace doesn't allow ${rec.repo}`}: nothing is committed to it from here` : `the org requires GitHub: ${by || 'the committer'}'s commits go when the person ${by && actor && actor.kind === 'agent' ? 'it works for' : 'committing'} connects a GitHub account that may write to ${rec.repo}`
660
- room.saveMeta(); room.broadcastBranches()
661
- return { state: 'refused', why: rec.problem }
662
- }
663
- token = t.token
664
- dir = (await room.mirrors.ensure(repo, { url: room.gitRemoteUrl(repo) })).dir
665
- } catch (err) { log(`[${room.name}] ${key}: could not open the mirror: ${err.message}`); return { state: 'error' } }
666
- return room.mirrors.queue(repo.slug, rec.ref, async () => {
667
- if (state.op) await recoverOp(room, key, { dir, token })
668
- // §8: one live session per repo@branch. Without the lease, this session's work goes to a branch of its own,
669
- // with a pull request (as for a protected branch); with the API out of reach, nobody pushes (addendum 16.6).
670
- if (room.leases && (!state.protected || state.protected.leased)) {
671
- const lease = await room.leases.ensure({ repo: rec.repo, branch: rec.ref, room: room.name })
672
- if (lease.error) { rec.problem = `commits to ${rec.ref} wait: the relay can't confirm this session holds the branch (${lease.error})`; room.saveMeta(); room.broadcastBranches(); return { state: 'paused', why: 'lease' } }
673
- if (lease.ok) {
674
- state.leaseGeneration = lease.generation
675
- if (state.protected && state.protected.leased) { delete state.protected; delete rec.leasedBy; delete rec.problem; log(`[${room.name}] ${key}: this session holds the live sync of ${rec.ref} again`) }
676
- } else {
677
- rec.leasedBy = lease.room
678
- if (!state.protected) state.protected = { since: Date.now(), head: null, pr: null, leased: true, entered: true, branch: `quilt/session-${room.name}/${rec.ref}` }
679
- }
680
- }
681
- for (let attempt = 1; attempt <= PUSH_RETRIES; attempt++) {
682
- const base = state.base
683
- const started = Date.now()
684
- try { if (!(await haveCommit(room, dir, base, [rec.ref, ...(rec.cutFrom && upstreamsOf(room)[rec.cutFrom] ? [upstreamsOf(room)[rec.cutFrom].ref] : [])], token))) { rec.problem = `the session's commit ${base.slice(0, 7)} on ${key} isn't on GitHub: the relay can't push on top of it`; room.saveMeta(); return { state: 'error' } } } catch (err) { return paused(key, state, rec, err) }
685
- // Protected mode (§5.5): commits go on the live branch, on top of what is there; a live branch that no longer descends from the base (its pull request merged or closed) restarts from the base.
686
- let live = null
687
- let upstream = null
688
- if (state.protected) {
689
- const fate = await liveFate(key, state, rec, repo, dir, token, base)
690
- if (fate === 'merged' || fate === 'gone' || (!state.protected.head && !state.protected.entered)) {
691
- // The live branch's pull request merged (or the live branch is gone): the old live branch goes, and the branch
692
- // itself is tried again first; its rules put the session back in protected mode if they still refuse. What the
693
- // merge didn't have is still a difference between the session and the new head, so it goes in the next commit.
694
- if (fate !== 'gone') { try { await deleteRemoteBranch(dir, state.protected.branch || liveBranchOf(rec.ref), { token }) } catch (err) { log(`[${room.name}] ${key}: could not remove the old live branch: ${err.message}`) } }
695
- log(`[${room.name}] ${key}: the live branch ${state.protected.branch || liveBranchOf(rec.ref)} ${fate === 'gone' ? 'was deleted on GitHub' : 'is merged'}; live work starts again from ${rec.ref} at ${base.slice(0, 7)}`)
696
- const wasLeased = state.protected.leased
697
- delete state.protected
698
- if (wasLeased) { attempt--; const l = await room.leases.ensure({ repo: rec.repo, branch: rec.ref, room: room.name }); if (!l.ok && !l.error) { state.protected = { since: Date.now(), head: null, pr: null, leased: true, entered: true, branch: `quilt/session-${room.name}/${rec.ref}` }; rec.leasedBy = l.room } ; continue }
699
- } else {
700
- live = state.protected.branch || liveBranchOf(rec.ref)
701
- if (fate === 'closed') { log(`[${room.name}] ${key}: pull request #${state.protected.pr ? state.protected.pr.number : '?'} was closed without merging; the live branch keeps the work and a new pull request opens with this push`); state.protected.pr = null; delete state.protected.closed }
702
- // The target moved past the live branch's start (other work merged into it) while its pull request is open:
703
- // the commit carries the session's own changes only, never the target's new ones again (no merge of histories).
704
- if (state.protected.head) {
705
- const fork = (await runGit(dir, ['merge-base', base, state.protected.head])).out.trim()
706
- if (fork && fork !== base) upstream = { target: base, fork }
707
- }
708
- }
709
- }
710
- const parent = live && state.protected.head ? state.protected.head : base
711
- let built
712
- try { built = await buildCommit(room, key, { dir, base: parent, message, by, only, now: started, upstream, own, lfs: { url: room.gitRemoteUrl(repo), token } }) } catch (err) { log(`[${room.name}] ${key}: could not build the commit: ${err.message}`); return { state: 'error', why: err.message } }
713
- if (built.others) return { state: 'others', others: built.others }
714
- tellSkipped(key, state, built.skipped)
715
- if (!built.sha) { for (const rel of Object.keys(state.pending)) if ((!only || only.includes(rel)) && !built.skipped.claimed.includes(rel) && !built.skipped.held.includes(rel) && !(built.skipped.renameWait || []).includes(rel)) delete state.pending[rel]; room.saveMeta(); return { state: 'nothing', skipped: built.skipped } }
716
- crashPoint(room, 'capture')
717
- // Addendum 16.11: the repository's push budget, shared by all its branches; a named commit waits for it too.
718
- const wait = room.mirrors.pushWait ? room.mirrors.pushWait(repo.slug) : 0
719
- if (wait > BUDGET_WAIT_MS) { schedule(key, { at: Date.now() + wait }); return { state: 'queued', in: wait } }
720
- if (wait > 0) { await new Promise((r) => setTimeout(r, wait)); attempt--; continue }
721
- // Addendum 16.6: right before pushing to the branch itself, the lease must still be good for a whole push.
722
- if (!live && room.leases && !room.leases.valid({ repo: rec.repo, branch: rec.ref, room: room.name })) {
723
- const l = await room.leases.ensure({ repo: rec.repo, branch: rec.ref, room: room.name })
724
- if (l.error) return { state: 'paused', why: 'lease' }
725
- if (!l.ok) { rec.leasedBy = l.room; state.protected = { since: Date.now(), head: null, pr: null, leased: true, entered: true, branch: `quilt/session-${room.name}/${rec.ref}` }; continue }
726
- state.leaseGeneration = l.generation
727
- }
728
- // Addendum 16.1: the intent is on disk before the push, so a relay that dies mid-push recognises its own commit on restart (recoverOp).
729
- state.op = { id: built.sha.slice(0, 12), kind: 'push', generation: state.leaseGeneration || null, branch: live || rec.ref, expected: live ? (state.protected.head || null) : base, candidate: built.sha, files: Object.fromEntries([...built.contents].map(([rel, b]) => [rel, b === null ? null : gitBlobSha(b)])), started }
730
- room.saveMeta()
731
- crashPoint(room, 'push')
732
- let r
733
- if (room.mirrors.spendPush) room.mirrors.spendPush(repo.slug)
734
- try { r = await room.mirrors.push(dir, { branch: live || rec.ref, sha: built.sha, expected: live ? (state.protected.head || null) : base, token }) } catch (err) { delete state.op; room.saveMeta(); return paused(key, state, rec, err) }
735
- if (r.state === 'slow') { delete state.op; room.saveMeta(); room.mirrors.slowDown && room.mirrors.slowDown(repo.slug, 60 * 1000); schedule(key, { at: Date.now() + 60 * 1000 }); log(`[${room.name}] ${key}: GitHub asked to slow down (${r.why}); the push waits a minute`); return { state: 'queued', in: 60 * 1000 } }
736
- if (r.state === 'accepted') crashPoint(room, 'ack')
737
- if (r.state !== 'accepted') { delete state.op; room.saveMeta() }
738
- if (r.state === 'protected' && !live) {
739
- // The branch's rules refuse direct pushes: from now on work goes through quilt/live/<branch> and one pull request.
740
- state.protected = { since: started, head: null, pr: null, why: r.why, entered: true } // just refused: the live branch starts now (no restart check)
741
- log(`[${room.name}] ${key}: ${rec.ref} is protected on GitHub (${r.why}); work goes through ${liveBranchOf(rec.ref)}`)
742
- continue
743
- }
744
- if (r.state === 'accepted') {
745
- if (live) { state.protected.head = built.sha } else { state.base = built.sha; state.baseAt = started }
746
- state.pushed = built.sha
747
- if (!state.pushedAt) state.pushedAt = {}
748
- for (const rel of built.changed) state.pushedAt[rel] = started
749
- delete state.op
750
- // Only what went in is cleared: a file edited after it was read stays pending (addendum 16.1).
751
- for (const rel of built.changed) if ((state.pending[rel] || 0) <= started && sessionId(room, key, rel) === (built.contents.get(rel) === null ? null : gitBlobSha(built.contents.get(rel)))) delete state.pending[rel]
752
- // A commit of some files (quilt_git_commit) leaves the rest pending: they are still the session's, uncommitted.
753
- for (const rel of Object.keys(state.pending)) if ((!only || only.includes(rel)) && !built.skipped.claimed.includes(rel) && !built.skipped.held.includes(rel) && !(built.skipped.renameWait || []).includes(rel) && !built.changed.includes(rel) && state.pending[rel] <= started) delete state.pending[rel]
754
- delete state.paused
755
- clearTimeout(retryTimers.get(key)); retryTimers.delete(key)
756
- const back = !!rec.gone
757
- if (!live) { relayAt(room, key, rec, built.sha); delete rec.problem; delete rec.gone }
758
- if (back && room.postChat) room.postChat({ by: RELAY_BY, text: `${rec.ref} had been deleted on GitHub: it is back, with the session's work (${built.sha.slice(0, 7)}).`, kind: 'commit' })
759
- await keepRefs(dir, room.name, key, { base: state.base, others: [...keptObjects(state), ...(live ? [built.sha] : [])] })
760
- notePublished(room.doc, new Map([...built.contents].filter(([, b]) => b)), 'relay-publish')
761
- notePushedMerges(room, key, built.sha, built.changed)
762
- const full = `${repo.owner}/${repo.name}`
763
- log(`[${room.name}] ${key}: pushed ${built.sha.slice(0, 7)} to ${full} ${live || rec.ref} (${plural(built.changed.length, 'file')}, by ${built.author})`)
764
- if (room.postChat) room.postChat({ by: RELAY_BY, text: `${built.author}'s edits went to ${full} ${live || rec.ref} as ${built.sha.slice(0, 7)}: ${built.changed.slice(0, 8).join(', ')}${built.changed.length > 8 ? ` and ${built.changed.length - 8} more` : ''}${built.coAuthors.length ? ` (with ${built.coAuthors.join(', ')})` : ''}. https://github.com/${full}/commit/${built.sha}`, kind: 'commit' })
765
- room.saveMeta(); room.broadcastBranches()
766
- if (!live && room.tellPushed) room.tellPushed(key, built.sha)
767
- gcMirror(dir).catch(() => {})
768
- if (Object.keys(state.pending).length) schedule(key)
769
- if (live) { state.protected.basedOn = base; try { await keepLivePull(key, state, rec, repo, token) } catch (err) { log(`[${room.name}] ${key}: could not keep the live pull request: ${err.message}`) } }
770
- if (!live && state.pr) noteTaskRevision(room, state.pr.number, built.sha)
771
- // An agent's own branch (quilt/<agent>/…): its pull request to the branch it was cut from opens with the first push, tied to its task (relay-commit.js).
772
- const agent = Object.values(room.meta.members || {}).find((m) => m && m.kind === 'agent' && m.name && rec.ref.startsWith(agentPrefix(m.name)))
773
- if (agent && rec.cutFrom && !state.pr && !live) {
774
- openPullOnPush(room, { who: { name: agent.name, kind: 'agent', access: {} }, key, branch: rec.ref, files: built.changed, subject: built.subject, revision: built.sha }, { fetch: room.githubFetch, log })
775
- .then((pr) => { if (pr) { state.pr = { number: pr.number, url: pr.url }; room.saveMeta() } })
776
- .catch((err) => log(`[${room.name}] ${key}: pushed, but could not open the pull request: ${err.message}`))
777
- }
778
- return { state: 'pushed', sha: built.sha, changed: built.changed, skipped: built.skipped, author: built.author, subject: built.subject, ...(live ? { live } : {}) }
779
- }
780
- if (r.state === 'protected') { return protectedMode(key, state, rec, r) }
781
- if (r.state === 'refused') { log(`[${room.name}] ${key}: push refused: ${r.why}`); return { state: 'error', why: r.why } }
782
- if (live) {
783
- // The live branch moved under us (someone pushed to it by hand): take its head as ours and build again on it.
784
- state.protected.head = r.head || null
785
- log(`[${room.name}] ${key}: ${live} moved on GitHub while pushing (now ${String(r.head || '').slice(0, 7)}); building on it (${attempt}/${PUSH_RETRIES})`)
786
- continue
787
- }
788
- // The remote moved: bring it in (inbound first), then build again on the new head.
789
- log(`[${room.name}] ${key}: ${rec.ref} moved on GitHub while pushing (now ${String(r.head || '').slice(0, 7)}); bringing it in first (${attempt}/${PUSH_RETRIES})`)
790
- const inb = await bringInFirst(room, key, log)
791
- if (!['brought', 'up-to-date'].includes(inb.state) || state.base === base) { rec.problem = `${rec.ref} moved on GitHub and the relay couldn't bring it in (${inb.state}); it tries again with the next change`; room.saveMeta(); return { state: 'moved', inbound: inb } }
792
- }
793
- schedule(key)
794
- return { state: 'moved' }
795
- })
796
- }
797
-
798
- const paused = (key, state, rec, err) => {
799
- const why = err.kind === 'auth' ? 'auth' : err.kind === 'missing' ? 'missing' : 'unreachable'
800
- const was = state.paused
801
- const start = Number.isFinite(cfg.backoffStartMs) ? cfg.backoffStartMs : OUTAGE_START_MS
802
- const max = Number.isFinite(cfg.backoffMaxMs) ? cfg.backoffMaxMs : OUTAGE_MAX_MS
803
- const backoffMs = why === 'unreachable' ? Math.min(max, was && was.why === 'unreachable' && was.backoffMs ? was.backoffMs * 2 : start) : 5 * 60 * 1000
804
- state.paused = { why, at: was && was.why === why ? was.at : Date.now(), text: err.message, backoffMs, until: Date.now() + backoffMs }
805
- const full = rec.repo
806
- rec.problem = why === 'auth' ? `GitHub refused the relay's sign-in for ${full} (app not installed on the repository, or its token revoked): commits wait in the session until a member connects GitHub again`
807
- : why === 'missing' ? `${full} isn't on GitHub (renamed, removed, or Quilt isn't installed on it): commits wait in the session`
808
- : `GitHub unreachable since ${new Date(state.paused.at).toISOString().slice(11, 16)} UTC: the session keeps working, and commits asked for go when it answers`
809
- log(`[${room.name}] ${key}: ${rec.problem}${why === 'unreachable' ? ` (next try in ${Math.round(backoffMs / 1000)} s)` : ''}`)
810
- const owner = room.meta && room.meta.ownerName
811
- if (owner && room.postChat && why !== 'unreachable' && (!state.told || Date.now() - state.told > 6 * 60 * 60 * 1000)) {
812
- state.told = Date.now()
813
- room.postChat({ by: RELAY_BY, to: owner, text: `Commits from ${key} to ${full} are paused: ${rec.problem}. Files keep syncing between everyone here meanwhile.`, kind: 'commit' })
814
- }
815
- // The next try comes by itself for a commit someone asked for (or with the relay's own timer on): on recovery, what GitHub has comes in first, then the commit goes out.
816
- if (cfg.syncAuto === true || (Array.isArray(state.asked) && state.asked.length)) {
817
- clearTimeout(retryTimers.get(key))
818
- const t = setTimeout(() => { retryTimers.delete(key); recover(key).catch(() => {}) }, backoffMs)
819
- t.unref?.()
820
- retryTimers.set(key, t)
821
- }
822
- room.saveMeta(); room.broadcastBranches()
823
- return { state: 'paused', why, retryIn: backoffMs }
824
- }
825
-
826
- /** The commits asked for and not made yet (GitHub out of reach, or the push budget spent), in the order asked. */
827
- const runAsked = async (key) => {
828
- const state = syncStateOf(room, key)
829
- let r = null
830
- for (const a of [...(Array.isArray(state.asked) ? state.asked : [])]) {
831
- r = await flush(key, { message: a.message, by: a.by, only: a.only, own: a.own })
832
- if (r.state === 'paused' || r.state === 'queued') return r
833
- if (r.state !== 'pushed' && r.state !== 'nothing') log(`[${room.name}] ${key}: the commit "${a.message}" asked for earlier couldn't be made (${r.state}${r.why ? `: ${r.why}` : ''})`)
834
- }
835
- return r
836
- }
837
-
838
- /** After an outage: inbound first, then outbound (plan §11). */
839
- const recover = async (key) => {
840
- const state = syncStateOf(room, key)
841
- if (!state.paused) return null
842
- state.paused.until = 0
843
- const inb = await bringInBranch(room, key, { log })
844
- if (['error', 'private', 'token-refused', 'missing'].includes(inb.state)) return paused(key, state, upstreamsOf(room)[key], Object.assign(new Error(inb.state), { kind: inb.state === 'error' ? 'unreachable' : 'auth' }))
845
- // The commits asked for meanwhile, in order; then the relay's own batch when its timer is on.
846
- const r = await runAsked(key)
847
- if (r && (r.state === 'paused' || r.state === 'queued')) return r
848
- delete state.paused
849
- if (cfg.syncAuto === true) return flush(key, { auto: true })
850
- room.saveMeta(); room.broadcastBranches()
851
- return r || { state: 'nothing' }
852
- }
853
-
854
- /**
855
- * Where the live branch stands (addendum 16.5), told apart from the target's head, the live head, the session and
856
- * what is pending: 'merged' (its work is in the target: it restarts from the target's head), 'gone' (deleted on
857
- * GitHub: it starts again from the target's head), 'closed' (its pull request closed unmerged: the branch keeps the
858
- * work, and a new pull request opens), or 'open'. GitHub's pull request says which once the target has moved past
859
- * the live branch's start; a merge commit or fast-forward that took the live head whole needs no asking.
860
- */
861
- const liveFate = async (key, state, rec, repo, dir, token, base) => {
862
- const p = state.protected
863
- if (!p.head) return 'open'
864
- if ((await runGit(dir, ['merge-base', '--is-ancestor', p.head, base])).ok) return 'merged'
865
- if (p.merged) return 'merged'
866
- let there
867
- try { there = (await remoteHead(dir, p.branch || liveBranchOf(rec.ref), { token })).sha } catch { there = undefined }
868
- if (there === null) return 'gone'
869
- const moved = !(await runGit(dir, ['merge-base', '--is-ancestor', base, p.head])).ok
870
- if (p.pr && moved && p.askedFor !== base && !p.closed) {
871
- try {
872
- const gh = githubWriter({ fetch: room.githubFetch || globalThis.fetch, token, owner: repo.owner, name: repo.name })
873
- const pr = await gh.pullRequest(p.pr.number)
874
- p.askedFor = base
875
- if (pr.merged) { p.merged = { at: Date.now(), ...(pr.sha ? { sha: pr.sha } : {}) }; return 'merged' }
876
- if (pr.state !== 'open') p.closed = Date.now()
877
- } catch (err) { log(`[${room.name}] ${key}: could not ask GitHub about pull request #${p.pr.number}: ${err.message}`) }
878
- }
879
- return p.closed ? 'closed' : 'open'
880
- }
881
-
882
- /** One pull request from the live branch into the protected branch ("Live work on <branch>"), opened once, and the panel's line. */
883
- const keepLivePull = async (key, state, rec, repo, token) => {
884
- if (!state.protected) return
885
- const live = state.protected.branch || liveBranchOf(rec.ref)
886
- const leased = !!state.protected.leased
887
- const name = room.meta.name || room.name
888
- if (!state.protected.pr) {
889
- const gh = githubWriter({ fetch: room.githubFetch || globalThis.fetch, token, owner: repo.owner, name: repo.name })
890
- const pr = await gh.pull({ title: leased ? `Live work from the session ${name} on ${rec.ref}` : `Live work on ${rec.ref}`, head: live, base: rec.ref, body: leased ? `${rec.ref} syncs with GitHub in another Quilt session, so this session's commits go through this pull request (one session per branch). Quilt keeps it open and adds each commit made in the session.` : `Commits from the Quilt session on \`${rec.ref}\`, which is protected: they go through this pull request. Quilt keeps it open and adds each commit made in the session; merge it when it is good.` })
891
- state.protected.pr = { number: pr.number, url: pr.url }
892
- if (!pr.existed && room.postChat) room.postChat({ by: RELAY_BY, text: leased ? `${rec.ref} syncs with GitHub in another session, so this session's commits go through pull request #${pr.number} (${live} → ${rec.ref}): ${pr.url}` : `${rec.ref} is protected on GitHub, so the session's commits go through pull request #${pr.number} (${live} → ${rec.ref}): ${pr.url}`, kind: 'commit' })
893
- }
894
- rec.problem = leased ? `${rec.ref} syncs with GitHub in another session: this session's commits go through PR #${state.protected.pr.number}` : `${rec.ref} is protected: work goes through PR #${state.protected.pr.number}`
895
- room.saveMeta(); room.broadcastBranches()
896
- }
897
-
898
- const protectedMode = (key, state, rec, r) => {
899
- state.protected = { at: Date.now(), why: r.why }
900
- rec.problem = `${rec.ref} is protected on GitHub (${r.why}): direct pushes are refused`
901
- log(`[${room.name}] ${key}: ${rec.problem}`)
902
- room.saveMeta(); room.broadcastBranches()
903
- return { state: 'protected', why: r.why }
904
- }
905
-
906
- /** Paths left out of a commit are said once each, to whoever last changed them. */
907
- const tellSkipped = (key, state, skipped) => {
908
- if (!state.told_paths) state.told_paths = {}
909
- const e = room.branchDoc(key)
910
- const history = e.doc.getArray('history').toArray()
911
- const lastBy = (rel) => { for (let i = history.length - 1; i >= 0; i--) if (history[i] && history[i].path === rel && !history[i].pulled && history[i].by && !isRelay(history[i].by)) return history[i].by; return null }
912
- const say = (rels, why) => {
913
- for (const rel of rels) {
914
- const k = `${why}\0${rel}`
915
- if (state.told_paths[k]) continue
916
- state.told_paths[k] = Date.now()
917
- delete state.pending[rel]
918
- const to = lastBy(rel)
919
- if (to && room.postChat) room.postChat({ by: RELAY_BY, to, kind: 'nudge', text: `${rel} isn't pushed to GitHub: ${why}.` })
920
- }
921
- }
922
- // Said each time (it is news each time), and the file goes in the commit: it stays pending until pushed.
923
- const tell = (rels, what) => {
924
- for (const rel of rels) {
925
- const to = lastBy(rel)
926
- if (to && room.postChat) room.postChat({ by: RELAY_BY, to, kind: 'nudge', text: `${rel}: ${what}.` })
927
- }
928
- }
929
- say(skipped.ignored, 'git ignores it (the repository\'s .gitignore, .quiltignore or Quilt\'s own list)')
930
- say(skipped.markers, 'it holds conflict markers that aren\'t a whole merge (a <<<<<<< without its ======= and >>>>>>>), so Quilt can\'t settle them; it goes with the next commit once they are gone')
931
- tell(skipped.settled || [], 'it held conflict markers from a git merge, so Quilt settled them: both sides\' lines are kept (where git recorded the base, the lines both sides changed are the first side\'s). It goes to GitHub as it is now')
932
- say(skipped.special, 'it is a symlink, a submodule or a Git LFS file on GitHub, which the relay never rewrites; change it from a folder with git')
933
- say(skipped.large, 'it is larger than Quilt syncs')
934
- say(skipped.filtered || [], 'the repository\'s .gitattributes put it through a filter, and the relay never runs a repository\'s filters (only text and line-ending rules); commit it from a folder with git')
935
- say(skipped.encrypted || [], 'it is a large file kept end-to-end encrypted, and the session owner hasn\'t let the relay read large files (Let the relay commit large files, in the commit panel); commit it from a folder with git')
936
- say(skipped.unreadable || [], 'the relay couldn\'t read it from storage (missing, or not the file the session has); it goes with the next commit once a member\'s app has stored it again')
937
- say(skipped.lfs || [], 'it is over GitHub\'s 100 MB limit for a file (or Git LFS refused it): set up Git LFS for it (git lfs track, which adds it to .gitattributes), and the relay sends it through LFS')
938
- }
939
-
940
- /** Stops every batch timer (the room is closing, or a test replaces the controller). */
941
- const stop = () => { for (const key of [...timers.keys()]) clear(key); for (const t of retryTimers.values()) clearTimeout(t); retryTimers.clear(); clearTimeout(tellTimer); tellTimer = null }
942
-
943
- return { note, schedule, flush, recover, clear: (key) => clear(key), stop, pending: (key) => Object.keys(syncStateOf(room, key).pending) }
944
- }
945
-
946
- /** While a folder on the branch has local commits ahead of GitHub, the relay's own batch waits this long at most, so the person's commit (message and all) goes first (plan §6). */
947
- export const FOLDER_AHEAD_WAIT_MS = 30 * 1000
948
- /** The largest bundle of local commits a folder may hand to the relay. */
949
- export const MAX_BUNDLE_BYTES = 50 * 1024 * 1024
950
-
951
- /**
952
- * A person's folder hands its local commits to the relay (it can't push them itself, or its branch
953
- * diverged): `bundle` is `git bundle create` of them. Each commit goes onto the branch's head on
954
- * GitHub with its own message and author: as it is when it already sits on the head (same commit,
955
- * same id), else replayed (git merge-tree, the same change on the new head). A commit whose change
956
- * GitHub already has (the relay pushed the session's copy of it) is skipped. A commit that changed
957
- * the same lines as GitHub is combined by Quilt (replayMerge: the newer edit keeps those lines).
958
- * Resolves { state: 'pushed', sha, published: [{ original, published }], skipped: [shas], merged } or
959
- * { state: 'nothing' | 'refused', ... }; 'nothing' says the `head` of GitHub's the session is at
960
- * (the folder follows that commit, never a newer one the session has yet to bring in: session.js publishLocal).
961
- */
962
- export async function replayBundle (room, key, { who, bundle, head, ref = '' }, { log = room.log || (() => {}) } = {}) {
963
- if (!liveSyncOn(room, key)) return { state: 'refused', why: 'this branch doesn\'t sync live with GitHub' }
964
- const buf = Buffer.isBuffer(bundle) ? bundle : Buffer.from(String(bundle || ''), 'base64')
965
- if (!buf.length || buf.length > MAX_BUNDLE_BYTES) return { state: 'refused', why: `a bundle of local commits is 1 byte to ${MAX_BUNDLE_BYTES / 1048576} MB` }
966
- if (!/^[0-9a-f]{40}$/.test(String(head || ''))) return { state: 'refused', why: 'say which commit the bundle ends at' }
967
- if (!/^refs\/heads\/[^\s~^:?*[\\]+$/.test(String(ref || ''))) return { state: 'refused', why: 'say which branch the bundle carries' }
968
- const rec = upstreamsOf(room)[key]
969
- const state = syncStateOf(room, key)
970
- const repo = parseRepo(`https://${rec.repo}`)
971
- const token = await room.gitToken(repo, { access: 'write' })
972
- const dir = (await room.mirrors.ensure(repo, { url: room.gitRemoteUrl(repo) })).dir
973
- return room.mirrors.queue(repo.slug, rec.ref, async () => {
974
- const scratch = fs.mkdtempSync(path.join(os.tmpdir(), 'quilt-bundle-'))
975
- try {
976
- const file = path.join(scratch, 'local.bundle')
977
- fs.writeFileSync(file, buf)
978
- const incoming = `refs/quilt/incoming/${room.name}/${Date.now()}`
979
- // What GitHub has that the session doesn't yet comes in first, before the folder's commits are in the mirror
980
- // (so they can't be taken for GitHub's): the base moves past it only once the session has it.
981
- await room.mirrors.fetch(dir, rec.ref, { token })
982
- const remoteHead = (await runGit(dir, ['rev-parse', '--verify', '-q', `refs/remotes/origin/${rec.ref}`])).out.trim()
983
- if (!remoteHead) return { state: 'refused', why: `${rec.ref} isn't on GitHub` }
984
- if (state.base !== remoteHead) {
985
- const inb = await bringInFirst(room, key, log)
986
- if (state.base !== remoteHead && !['brought', 'up-to-date', 'recovered'].includes(inb.state)) return { state: 'refused', why: `GitHub moved and the relay couldn't bring it in yet (${inb.state}): try again` }
987
- }
988
- // The bundle's prerequisites (the folder's view of GitHub) are in the mirror now.
989
- const f = await runGit(dir, ['fetch', '-q', file, `${ref}:${incoming}`])
990
- if (!f.ok) return { state: 'refused', why: `the bundle didn't apply to the mirror: ${f.err.trim().split('\n').pop()}` }
991
- if ((await runGit(dir, ['rev-parse', incoming])).out.trim() !== head) { await runGit(dir, ['update-ref', '-d', incoming]); return { state: 'refused', why: 'the bundle doesn\'t end at the commit it says' } }
992
- return replayIncoming(room, key, { who, dir, incoming, head, remoteHead, token, log })
993
- } finally { fs.rmSync(scratch, { recursive: true, force: true }) }
994
- })
995
- }
996
-
997
- /**
998
- * A folder's commits, in the mirror at ref `incoming` (ending at `head`), onto GitHub's head
999
- * `remoteHead` of the session branch `key`: each one on the head already goes as it is; one whose
1000
- * change GitHub has already is skipped; any other is replayed with git merge-tree, keeping its author
1001
- * and message; lines both it and GitHub changed are combined (replayMerge). Then one fast-forward
1002
- * push. Resolves { state: 'pushed', sha, published, skipped, merged }, 'nothing', 'refused',
1003
- * 'protected' or 'moved'. Runs in the branch's queue.
1004
- */
1005
- async function replayIncoming (room, key, { who, dir, incoming, head, remoteHead, token, log }) {
1006
- const rec = upstreamsOf(room)[key]
1007
- const state = syncStateOf(room, key)
1008
- {
1009
- {
1010
- const list = (await runGit(dir, ['rev-list', '--reverse', '--topo-order', `${remoteHead}..${incoming}`])).out.split('\n').filter(Boolean)
1011
- await runGit(dir, ['update-ref', '-d', incoming])
1012
- if (!list.length) return { state: 'nothing', head: remoteHead, why: 'GitHub already has those commits' }
1013
- // On GitHub's head already (merge commits and all): they go as they are, a fast-forward.
1014
- const ff = (await runGit(dir, ['merge-base', '--is-ancestor', remoteHead, head])).ok
1015
- if (!ff && (await runGit(dir, ['rev-list', '--merges', `${remoteHead}..${head}`])).out.trim()) return { state: 'refused', why: 'merge commits aren\'t replayed onto a newer head: merge GitHub\'s branch into yours and push again, or let the session sync the files' }
1016
- let tip = remoteHead
1017
- const published = []
1018
- const skipped = []
1019
- const merged = [] // files Quilt combined on the way: { path, lost, deleted, at: the local commit, github }
1020
- if (ff) { tip = head; for (const c of list) published.push({ original: c, published: c }) }
1021
- for (const c of ff ? [] : list) {
1022
- const parent = (await runGit(dir, ['rev-parse', `${c}^`])).out.trim()
1023
- if (parent === tip) { tip = c; published.push({ original: c, published: c }); continue } // already on the head: the commit itself
1024
- const mt = await runGit(dir, ['merge-tree', '--write-tree', '--name-only', '--no-messages', `--merge-base=${parent}`, tip, c])
1025
- let tree = mt.out.split('\n')[0].trim()
1026
- if (!/^[0-9a-f]{40}$/.test(tree)) return { state: 'refused', why: `git couldn't replay ${c.slice(0, 7)} onto GitHub's head` }
1027
- if (!mt.ok) {
1028
- // Lines this commit and GitHub both changed: Quilt combines them (the newer edit keeps those lines), nobody is asked.
1029
- const files = [...new Set(mt.out.split('\n').slice(1).map((l) => l.trim()).filter(Boolean))]
1030
- const r = await replayMerge(room, dir, { parent, tip, commit: c, tree, files })
1031
- if (!r) return { state: 'refused', why: `could not combine ${c.slice(0, 7)} with GitHub's changes` }
1032
- tree = r.tree
1033
- merged.push(...r.merged)
1034
- }
1035
- const tipTree = (await runGit(dir, ['rev-parse', `${tip}^{tree}`])).out.trim()
1036
- if (tree === tipTree) { skipped.push(c); continue } // GitHub has this change already (the relay pushed the session's copy)
1037
- const meta = (await runGit(dir, ['log', '-1', '--format=%an%x1f%ae%x1f%aI%x1f%B', c])).out
1038
- const [an, ae, ad, ...msg] = meta.split('\x1f')
1039
- const committer = (room.cfg && room.cfg.committer) || COMMITTER
1040
- const made = await runGit(dir, ['commit-tree', tree, '-p', tip, '-F', '-'], { input: msg.join('\x1f').replace(/\n+$/, '') + '\n', env: { GIT_AUTHOR_NAME: an, GIT_AUTHOR_EMAIL: ae, GIT_AUTHOR_DATE: ad, GIT_COMMITTER_NAME: committer.name, GIT_COMMITTER_EMAIL: committer.email } })
1041
- if (!made.ok) return { state: 'refused', why: `could not replay ${c.slice(0, 7)}` }
1042
- tip = made.out.trim()
1043
- published.push({ original: c, published: tip })
1044
- }
1045
- if (tip === remoteHead) return { state: 'nothing', head: remoteHead, skipped, why: 'GitHub already has every change in those commits' }
1046
- crashPoint(room, 'replay') // tests: the relay dies with the commits replayed, before pushing them
1047
- const r = await room.mirrors.push(dir, { branch: rec.ref, sha: tip, expected: remoteHead, token })
1048
- if (r.state !== 'accepted') return { state: r.state === 'protected' ? 'protected' : 'moved', why: r.why || 'GitHub moved meanwhile: try again' }
1049
- crashPoint(room, 'replay-ack') // tests: GitHub took them, and the relay dies before it notes so
1050
- // The branch moved by the person's commits: the session's base follows; what they changed is in the session already.
1051
- state.base = tip; state.pushed = tip; state.baseAt = Date.now()
1052
- relayAt(room, key, rec, tip)
1053
- if (!Array.isArray(state.replays)) state.replays = []
1054
- state.replays.push({ by: who.name, at: Date.now(), published, skipped })
1055
- state.replays = state.replays.slice(-50)
1056
- room.saveMeta(); room.broadcastBranches()
1057
- if (merged.length) sessionTakesMerges(room, key, { who, rec, tip, remoteHead, merged })
1058
- if (room.tellPushed) room.tellPushed(key, tip)
1059
- log(`[${room.name}] ${key}: pushed ${published.length} local commit${published.length === 1 ? '' : 's'} of ${who.name} (${tip.slice(0, 7)})${skipped.length ? `, ${skipped.length} already on GitHub` : ''}${merged.length ? `; combined ${merged.map((m) => m.path).join(', ')} with GitHub's changes` : ''}`)
1060
- if (room.postChat && !who.quiet) room.postChat({ by: RELAY_BY, text: `${who.name}'s ${published.length} local commit${published.length === 1 ? '' : 's'} went to ${rec.repo.replace(/^github\.com\//, '')} ${rec.ref} (${tip.slice(0, 7)}).`, kind: 'commit' })
1061
- return { state: 'pushed', sha: tip, published, skipped, ...(merged.length ? { merged: merged.map((m) => ({ path: m.path, lost: m.lost.length ? m.lost[0].side : null })) } : {}) }
1062
- }
1063
- }
1064
- }
1065
-
1066
- /** A blob's key as planCatchUp keys files: text, "bin:<sha1>", or null when absent. */
1067
- const keyOfBuf = (buf) => buf === null || buf === undefined ? null : looksBinary(buf) ? `bin:${crypto.createHash('sha1').update(buf).digest('hex')}` : buf.toString('utf8')
1068
-
1069
- /**
1070
- * The files git merge-tree couldn't merge while replaying `commit` onto `tip`, combined by Quilt
1071
- * (upstream.js combine) against `parent`: lines only one side changed are kept, lines both changed
1072
- * go to the newer edit (the local commit's time against GitHub's newest commit to the file), an
1073
- * edit beats a deletion, a binary is the newer version. Returns { tree, merged: [{ path, lost,
1074
- * deleted, at, key, github }] } with `tree` merge-tree's with those files replaced, or null.
1075
- */
1076
- async function replayMerge (room, dir, { parent, tip, commit, tree, files }) {
1077
- const [atBase, atTip, atMine] = await Promise.all([filesAt(dir, parent, files), filesAt(dir, tip, files), filesAt(dir, commit, files)])
1078
- const mineAt = Number((await runGit(dir, ['log', '-1', '--format=%ct', commit])).out.trim()) * 1000 || 0
1079
- const modes = new Map()
1080
- for (const at of [tip, commit]) {
1081
- const r = await runGit(dir, ['ls-tree', '-z', at, '--', ...files])
1082
- for (const entry of r.out.split('\0')) { const tab = entry.indexOf('\t'); if (tab > 0 && !modes.has(entry.slice(tab + 1))) modes.set(entry.slice(tab + 1), entry.slice(0, tab).split(' ')[0]) }
1083
- }
1084
- const scratch = fs.mkdtempSync(path.join(os.tmpdir(), 'quilt-replay-'))
1085
- const env = { GIT_DIR: dir, GIT_INDEX_FILE: path.join(scratch, 'index') }
1086
- const merged = []
1087
- try {
1088
- if (!(await runGit(scratch, ['read-tree', tree], { env })).ok) return null
1089
- for (const rel of files) {
1090
- const bufs = { base: atBase.get(rel), tip: atTip.get(rel), mine: atMine.get(rel) }
1091
- if ([bufs.base, bufs.tip, bufs.mine].some((b) => b === undefined)) return null
1092
- const top = (await upstreamCommits(dir, parent, tip, rel))[0] || null
1093
- const mineNewer = mineAt >= ((top || {}).ts || 0)
1094
- // The local commit is "ours": its lines lost are told to whoever handed it over.
1095
- const r = combine(keyOfBuf(bufs.base), keyOfBuf(bufs.mine), keyOfBuf(bufs.tip), { oursNewer: mineNewer })
1096
- let out = null
1097
- if (r.key !== null) out = r.key === keyOfBuf(bufs.mine) ? bufs.mine : r.key === keyOfBuf(bufs.tip) ? bufs.tip : Buffer.from(r.key, 'utf8')
1098
- if (out === null) {
1099
- if (!(await runGit(scratch, ['update-index', '--force-remove', '--', rel], { env })).ok) return null
1100
- } else {
1101
- const blob = await runGit(scratch, ['hash-object', '-w', '--stdin'], { input: out, env })
1102
- if (!blob.ok) return null
1103
- if (!(await runGit(scratch, ['update-index', '--add', '--cacheinfo', `${modes.get(rel) || '100644'},${blob.out.trim()},${rel}`], { env })).ok) return null
1104
- }
1105
- merged.push({ path: rel, lost: r.lost, deleted: r.deleted || null, at: commit, key: r.key, mine: keyOfBuf(bufs.mine), bins: out && r.key && r.key.startsWith('bin:') ? out : null, github: top ? { name: top.name, email: top.email, ts: top.ts, sha: top.sha, member: memberForAuthor(room, top) } : null })
1106
- }
1107
- // A conflict merge-tree records at more than one stage (rename and delete, say) is gone once each path is set above.
1108
- const w = await runGit(scratch, ['write-tree'], { env })
1109
- if (!w.ok) return null
1110
- return { tree: w.out.trim(), merged }
1111
- } finally { fs.rmSync(scratch, { recursive: true, force: true }) }
1112
- }
1113
-
1114
- /**
1115
- * After a replay that combined files (replayMerge): the session takes the combined version of each
1116
- * one it still has as the local commit had it (else it would push that back over GitHub's lines),
1117
- * and whoever's lines lost is told: the person who handed the commits over, or the activity log.
1118
- */
1119
- function sessionTakesMerges (room, key, { who, rec, tip, remoteHead, merged }) {
1120
- const e = room.branchDoc ? room.branchDoc(key) : null
1121
- if (!e) return
1122
- const writes = new Map()
1123
- const bins = new Map()
1124
- for (const m of merged) {
1125
- const now = docKey(e, m.path)
1126
- if (now === undefined || now === m.key || now === FOLLOWS_GIT) continue // FOLLOWS_GIT: git's, so the merged commit is the file already
1127
- if (now === m.mine) { writes.set(m.path, m.key); if (m.bins) bins.set(m.key, m.bins); continue }
1128
- // Edited in the session since: that edit and the combined file, the session's edit kept where they overlap.
1129
- const r = combine(m.mine, now, m.key, { oursNewer: true })
1130
- if (r.key !== now) { writes.set(m.path, r.key); if (m.bins && r.key === m.key) bins.set(m.key, m.bins) }
1131
- }
1132
- if (writes.size) applyWrites(room, e, writes, { bins, now: Date.now() })
1133
- for (const m of merged) tellAutoMerge(room, key, { path: m.path, lost: m.lost, deleted: m.deleted, session: { name: who.name }, github: m.github, upstream: rec.name, head: remoteHead, keptIn: `your commit ${m.at.slice(0, 7)}` })
1134
- }
1135
-
1136
- /**
1137
- * A push into the session's git view (relay-githttp.js), checked before git moves any ref (plan §9):
1138
- * branches only, never a deletion or a rewrite; an agent within the owner's policy ('off': nothing;
1139
- * 'branches': quilt/<agent>/… only; 'any': everything); no newly added file the repository's
1140
- * .gitignore ignores; credentials for whoever pushes (the workspace's access mode, the org's Require
1141
- * GitHub). Then the commits go to GitHub: onto a session branch's head through replayIncoming (GitHub
1142
- * moved: replayed), or straight to any other branch, fast-forward only. `env` is the hook's git
1143
- * environment (the pushed objects are in its quarantine). Resolves { ok, message, published: Map ref -> sha }.
1144
- */
1145
- export async function receiveAgentPush (room, { who, name, updates, env }, { log = room.log || (() => {}) } = {}) {
1146
- const say = (message) => ({ ok: false, message: `Quilt: ${message}` })
1147
- const kind = String(who).startsWith('agent:') ? 'agent' : 'human'
1148
- const recs = upstreamsOf(room)
1149
- const def = recs[room.defaultKey] || Object.values(recs).find((r) => r && r.repo)
1150
- if (!def || !/^github\.com\//.test(def.repo || '')) return say('this session doesn\'t follow a GitHub repository')
1151
- const repo = parseRepo(`https://${def.repo}`)
1152
- const dir = (await room.mirrors.ensure(repo, { url: room.gitRemoteUrl(repo) })).dir
1153
- const t = room.gitTokenState ? await room.gitTokenState(repo, { access: 'write', actingFor: who }) : { token: await room.gitToken(repo, { access: 'write' }) }
1154
- if (!t.token) return say(t.state === 'not-allowed' ? `${t.why || `this workspace doesn't allow ${def.repo}`}, so nothing goes to GitHub from here` : t.require ? `the org requires GitHub: the person ${kind === 'agent' ? 'you work for' : 'pushing'} must connect a GitHub account that may write to ${def.repo.replace(/^github\.com\//, '')}` : `nobody in this session has connected GitHub with write access to ${def.repo.replace(/^github\.com\//, '')} (Connect GitHub in the app)`)
1155
- const token = t.token
1156
- // The push's objects are read from its quarantine (GIT_OBJECT_DIRECTORY); GIT_QUARANTINE_PATH itself stays out, or the mirror would refuse to take them.
1157
- const qenv = Object.fromEntries(Object.entries(env || {}).filter(([k, v]) => /^GIT_(DIR|OBJECT_DIRECTORY|ALTERNATE_OBJECT_DIRECTORIES)$/.test(k) && typeof v === 'string' && v))
1158
- const published = new Map()
1159
- const lines = []
1160
- for (const u of updates) {
1161
- if (!/^refs\/heads\/./.test(u.ref)) return say(`only branches are pushed through Quilt (not ${u.ref})`)
1162
- const branch = u.ref.slice('refs/heads/'.length)
1163
- if (!u.new) return say(`Quilt never deletes ${branch} on GitHub from a folder: ask a person to delete it there`)
1164
- const policy = agentCommitsOf(room)
1165
- // Git off is off for everyone (git for everyone): people's pushes too.
1166
- if (policy === 'off') return say(kind === 'agent' ? 'git is off for everyone in this session, so agents don\'t push here: ask a person for the commit with quilt_request_commit' : 'git is off for everyone in this session')
1167
- if (kind === 'agent') {
1168
- if (policy === 'branches' && !branch.startsWith(agentPrefix(name))) return say(`the session owner lets agents push only to branches of their own: ${agentPrefix(name)}<topic> (not ${branch}). Push there (git push origin HEAD:${agentPrefix(name)}<topic>); its pull request opens with the push`)
1169
- }
1170
- // The pushed commits into the mirror, from the push's quarantine, at a ref of their own.
1171
- const incoming = `refs/quilt/incoming/${room.name}/${Date.now()}-${published.size}`
1172
- const moved = await runGit(qenv.GIT_DIR || dir, ['push', '-q', dir, `${u.new}:${incoming}`], { env: qenv })
1173
- if (!moved.ok) { log(`[${room.name}] ${name}'s push: copying the commits into the mirror failed: ${moved.err.trim()}`); return say(`the relay couldn't take the commits (${moved.err.trim().split('\n').filter((l) => /error|fatal|rejected/i.test(l))[0] || 'git failed'})`) }
1174
- try {
1175
- // No file git ignores comes in by a push (a git add -f): the repository's .gitignore files as the pushed commit has them.
1176
- const base = u.old || (await runGit(dir, ['merge-base', u.new, `refs/remotes/origin/${def.ref}`])).out.trim() || null
1177
- const added = base ? (await runGit(dir, ['diff', '--name-only', '--no-renames', '--diff-filter=A', '-z', base, u.new])).out.split('\0').filter(Boolean) : (await runGit(dir, ['ls-tree', '-r', '--name-only', '-z', u.new])).out.split('\0').filter(Boolean)
1178
- const ignored = await ignoredInCommit(dir, u.new, added)
1179
- if (ignored.length) return say(`${ignored.slice(0, 5).join(', ')}${ignored.length > 5 ? ', …' : ''} ${ignored.length === 1 ? 'is' : 'are'} ignored by the repository's .gitignore: take ${ignored.length === 1 ? 'it' : 'them'} out of the commit (git rm --cached) and push again`)
1180
- const key = room.resolveKey ? room.resolveKey(branch) : branch
1181
- const rec = recs[key]
1182
- if (rec && rec.ref === branch && rec.repo === def.repo && liveSyncOn(room, key)) {
1183
- // A branch of the session: GitHub's newer commits come in first, then these go on its head.
1184
- const state = syncStateOf(room, key)
1185
- const r = await room.mirrors.queue(repo.slug, rec.ref, async () => {
1186
- await room.mirrors.fetch(dir, rec.ref, { token })
1187
- const remoteHead = (await runGit(dir, ['rev-parse', '--verify', '-q', `refs/remotes/origin/${rec.ref}`])).out.trim()
1188
- if (!remoteHead) return { state: 'refused', why: `${rec.ref} isn't on GitHub` }
1189
- if (state.base !== remoteHead) {
1190
- const inb = await bringInFirst(room, key, log)
1191
- if (state.base !== remoteHead && !['brought', 'up-to-date', 'recovered'].includes(inb.state)) return { state: 'refused', why: `GitHub moved and the relay couldn't bring it in yet (${inb.state}): try again` }
1192
- }
1193
- if (u.old && !(await runGit(dir, ['merge-base', '--is-ancestor', u.old, u.new])).ok) return { state: 'refused', why: 'that push would rewrite the branch' }
1194
- return replayIncoming(room, key, { who: { name, quiet: true }, dir, incoming, head: u.new, remoteHead, token, log })
1195
- })
1196
- if (r.state === 'nothing') { published.set(u.ref, (await runGit(dir, ['rev-parse', `refs/remotes/origin/${rec.ref}`])).out.trim()); lines.push(`${branch}: GitHub already has these changes`); continue }
1197
- if (r.state !== 'pushed') return say(r.state === 'protected' ? `${branch} is protected on GitHub: push to a branch of your own and open a pull request` : r.why || `GitHub didn't take it (${r.state}): fetch and push again`)
1198
- published.set(u.ref, r.sha)
1199
- lines.push(r.sha === u.new ? `${branch} on GitHub is at ${r.sha.slice(0, 7)}` : `${branch}: GitHub had moved, so your commits went on its head as ${r.sha.slice(0, 7)}${r.merged ? ` (Quilt combined ${r.merged.map((m) => m.path).join(', ')} with GitHub's changes: where both changed the same lines, the newer edit is kept)` : ''}; your folder follows it`)
1200
- continue
1201
- }
1202
- // Any other branch (an agent's own, new or not yet in the session): straight to GitHub, fast-forward only.
1203
- const r = await room.mirrors.queue(repo.slug, branch, async () => {
1204
- const remote = (await room.mirrors.fetch(dir, branch, { token }).catch(() => ({ sha: null }))).sha || null
1205
- if (remote && !(await runGit(dir, ['merge-base', '--is-ancestor', remote, u.new])).ok) return { state: 'moved', why: `${branch} moved on GitHub: fetch, merge or rebase onto it, and push again (never a force-push)` }
1206
- if (remote === u.new) return { state: 'accepted' }
1207
- return room.mirrors.push(dir, { branch, sha: u.new, expected: remote, token })
1208
- })
1209
- if (r.state !== 'accepted') return say(r.state === 'protected' ? `${branch} is protected on GitHub: ${r.why}` : r.why || `GitHub didn't take ${branch} (${r.state})`)
1210
- published.set(u.ref, u.new)
1211
- if (!room.meta.viewBranches || typeof room.meta.viewBranches !== 'object') room.meta.viewBranches = {}
1212
- room.meta.viewBranches[branch] = u.new
1213
- room.saveMeta()
1214
- lines.push(`${branch} on GitHub is at ${u.new.slice(0, 7)}`)
1215
- } finally { await runGit(dir, ['update-ref', '-d', incoming]) }
1216
- }
1217
- log(`[${room.name}] ${name} pushed through the relay: ${lines.join('; ')}`)
1218
- return { ok: true, message: `Quilt: ${lines.join('; ')}.`, published }
1219
- }
1220
-
1221
- /** Which of `paths` (added in commit `sha`) the repository's .gitignore files, as that commit has them, ignore. */
1222
- async function ignoredInCommit (dir, sha, paths) {
1223
- if (!paths.length) return []
1224
- const files = (await runGit(dir, ['ls-tree', '-r', '--name-only', '-z', sha])).out.split('\0').filter((f) => f === '.gitignore' || f.endsWith('/.gitignore'))
1225
- if (!files.length) return []
1226
- const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'quilt-push-ignore-'))
1227
- try {
1228
- for (const f of files) {
1229
- const r = await runGit(dir, ['cat-file', 'blob', `${sha}:${f}`], { buffer: true })
1230
- if (!r.ok) continue
1231
- fs.mkdirSync(path.dirname(path.join(tmp, f)), { recursive: true })
1232
- fs.writeFileSync(path.join(tmp, f), r.out)
1233
- }
1234
- const r = await runGit(tmp, ['check-ignore', '-z', '--stdin', '--no-index'], { input: paths.join('\0') + '\0', env: { GIT_DIR: dir, GIT_WORK_TREE: tmp } })
1235
- return (r.out || '').split('\0').filter(Boolean)
1236
- } finally { fs.rmSync(tmp, { recursive: true, force: true }) }
1237
- }
1238
-
1239
- /**
1240
- * A person adopts an agent's work (policy "off", or any agent branch): every file the agent's
1241
- * branch `from` changed since it was cut (or its base) is written into branch `key` as the
1242
- * person's own edit, and pushed now as one commit, "<agent>'s work from <from>, adopted by
1243
- * <person>" (an explicit publication boundary, addendum 16.7). Resolves the push's result.
1244
- */
1245
- export async function adoptBranch (room, key, { who, from }) {
1246
- if (who.kind === 'agent') throw new Error('A person adopts an agent\'s work.')
1247
- const src = room.resolveKey ? room.resolveKey(from) : from
1248
- if (!room.meta.branches[src]) throw new Error(`${from} isn't in this session.`)
1249
- if (src === key) throw new Error('Adopt from another branch than the one you are on.')
1250
- const recs = upstreamsOf(room)
1251
- const srec = recs[src] || {}
1252
- const sstate = syncStateOf(room, src)
1253
- const base = srec.cutAt || sstate.base || srec.sha || (recs[key] && recs[key].sha) // everything since the branch started
1254
- if (!base) throw new Error(`Quilt doesn't know what ${from} started from.`)
1255
- const rec = recs[key]
1256
- if (!rec || !rec.repo) throw new Error('This branch doesn\'t follow a GitHub repository.')
1257
- const repo = parseRepo(`https://${rec.repo}`)
1258
- const dir = (await room.mirrors.ensure(repo, { url: room.gitRemoteUrl(repo) })).dir
1259
- const token = await room.gitToken(repo)
1260
- if (!(await haveCommit(room, dir, base, [rec.ref, srec.ref, srec.cutFrom && recs[srec.cutFrom] ? recs[srec.cutFrom].ref : null], token))) throw new Error(`${base.slice(0, 7)}, where ${from} started, isn't on GitHub.`)
1261
- const tree = await treeOf(dir, base)
1262
- const se = room.branchDoc(src)
1263
- const te = room.branchDoc(key)
1264
- const changes = new Map()
1265
- for (const rel of new Set([...se.files.keys(), ...se.blobs.keys()])) {
1266
- const b = sessionBytes(se, rel)
1267
- if (b === undefined) continue
1268
- const was = tree.get(rel)
1269
- if (!was || was.sha !== gitBlobSha(b)) changes.set(rel, b)
1270
- }
1271
- // A diff branch says what it deleted; a full one has every file it keeps, so what it lacks is deleted.
1272
- if (isDiff(se)) { for (const rel of se.gone.keys()) if (tree.has(rel)) changes.set(rel, null) } else for (const rel of tree.keys()) if (!se.files.has(rel) && !se.blobs.has(rel)) changes.set(rel, null)
1273
- if (!changes.size) return { state: 'nothing', why: `${from} has no changes since it started` }
1274
- const { HistoryLog } = await import('./history.js')
1275
- const Y = await import('yjs')
1276
- const { applyTextDiff } = await import('./textdiff.js')
1277
- const history = te.historyLog || (te.historyLog = new HistoryLog(te.doc, te.doc.getArray('history'), { origin: 'relay-adopt' }))
1278
- const tdiff = isDiff(te)
1279
- const tbase = tdiff ? (syncStateOf(room, key).base || rec.sha) : null
1280
- te.doc.transact(() => {
1281
- for (const [rel, b] of changes) {
1282
- const before = te.files.get(rel)?.toString()
1283
- if (b === null) { if (tdiff) removePath(te, rel, { by: who.name, base: tbase }); else { te.files.delete(rel); te.blobs.delete(rel) } history.record({ by: who.name, path: rel, kind: 'deleted', before, after: '' }); continue }
1284
- if (tdiff) notePresent(te, rel, { base: tbase })
1285
- if (b.includes(0)) { te.files.delete(rel); te.blobs.set(rel, { hash: crypto.createHash('sha1').update(b).digest('hex'), data: b.toString('base64') }); history.record({ by: who.name, path: rel, kind: before === undefined && !(tdiff && tree.has(rel)) ? 'created' : 'edited', detail: `${b.length} bytes` }); continue }
1286
- let y = te.files.get(rel)
1287
- if (!y) { y = new Y.Text(); te.files.set(rel, y) }
1288
- applyTextDiff(y, b.toString('utf8'))
1289
- history.record({ by: who.name, path: rel, kind: before === undefined ? 'created' : 'edited', before: before ?? '', after: b.toString('utf8') })
1290
- }
1291
- }, 'relay-adopt')
1292
- for (const rel of changes.keys()) room.outbound.note(key, rel)
1293
- const owner = agentOwnerOf(room, src)
1294
- return room.outbound.flush(key, { message: `${owner ? `${owner}'s work` : 'The work'} from ${src}, adopted by ${who.name}`, by: who.name, only: [...changes.keys()] })
1295
- }