heyquilt 0.0.1 → 0.3.90

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