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