heyquilt 0.3.90 → 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.
Files changed (84) hide show
  1. package/README.md +14 -11
  2. package/RELEASES.md +131 -2
  3. package/bin/quilt.js +45 -12
  4. package/package.json +3 -3
  5. package/src/account.js +28 -0
  6. package/src/agents/claude-code.js +11 -4
  7. package/src/agents/cursor-transcripts.js +11 -4
  8. package/src/ai-vm-signin.js +14 -2
  9. package/src/ai-vm.js +150 -8
  10. package/src/api/agent-auth.js +9 -3
  11. package/src/api/fly-machines.js +72 -15
  12. package/src/api/memory-store.js +141 -5
  13. package/src/api/relay-cluster.js +337 -43
  14. package/src/api/relay-machines.js +35 -3
  15. package/src/api/releases.js +87 -0
  16. package/src/api/routes/agents.js +111 -16
  17. package/src/api/routes/ai-vm.js +143 -17
  18. package/src/api/routes/orgs.js +3 -1
  19. package/src/api/routes/secrets.js +448 -0
  20. package/src/api/routes/workspaces.js +8 -1
  21. package/src/api/sandbox-keys.js +57 -0
  22. package/src/api/secrets-access.js +93 -0
  23. package/src/api/server.js +40 -9
  24. package/src/api/supabase-store.js +120 -10
  25. package/src/autowork.js +3 -15
  26. package/src/catchup.js +1 -1
  27. package/src/clash.js +10 -22
  28. package/src/control.js +18 -15
  29. package/src/fsutil.js +11 -1
  30. package/src/gitstate.js +29 -5
  31. package/src/hooks.js +1 -1
  32. package/src/mcp-proxy.js +7 -2
  33. package/src/mcp.js +22 -25
  34. package/src/merge3.js +94 -12
  35. package/src/pass-source.js +9 -2
  36. package/src/pathrules.js +7 -0
  37. package/src/relay-clash.js +101 -484
  38. package/src/relay-githttp.js +36 -7
  39. package/src/relay-mcp.js +19 -7
  40. package/src/relay-metrics.js +66 -1
  41. package/src/relay-outbound.js +154 -25
  42. package/src/relay-sync.js +133 -136
  43. package/src/relay-turn-sync.js +250 -51
  44. package/src/relay-upstream.js +49 -179
  45. package/src/releases.js +16 -9
  46. package/src/runner.js +3 -2
  47. package/src/secret-import.js +55 -0
  48. package/src/secret-names.js +26 -0
  49. package/src/secret-run.js +80 -0
  50. package/src/secrets-client.js +56 -0
  51. package/src/server.js +71 -44
  52. package/src/session.js +347 -262
  53. package/src/status.js +3 -5
  54. package/src/sync-words.js +1 -1
  55. package/src/turn-sync-plan.js +13 -10
  56. package/src/turn-sync.js +32 -11
  57. package/src/ui/agent-guide.js +4 -4
  58. package/src/ui/app.css +208 -23
  59. package/src/ui/app.js +4 -0
  60. package/src/ui/cloud-ai-model.js +3 -2
  61. package/src/ui/common.js +2 -0
  62. package/src/ui/fileview.js +28 -1
  63. package/src/ui/home.js +32 -33
  64. package/src/ui/loom.css +18 -2
  65. package/src/ui/loom.js +41 -8
  66. package/src/ui/rail-model.js +6 -9
  67. package/src/ui/releases.js +1 -1
  68. package/src/ui/sandbox-keys-model.js +53 -0
  69. package/src/ui/sandbox-keys.js +132 -0
  70. package/src/ui/secret-rules.js +255 -0
  71. package/src/ui/secrets-view.js +162 -0
  72. package/src/ui/secrets.js +283 -0
  73. package/src/ui/session.js +47 -149
  74. package/src/ui/tree.js +23 -18
  75. package/src/ui/workspaces.js +5 -0
  76. package/src/ui-server.js +136 -27
  77. package/src/update-check.js +1 -1
  78. package/src/upstream.js +75 -38
  79. package/src/vault/crypto.js +96 -0
  80. package/src/vault/kms.js +61 -0
  81. package/src/vault/main.js +33 -0
  82. package/src/vault/server.js +279 -0
  83. package/src/vault/sigv4.js +38 -0
  84. package/src/ui/merges.js +0 -158
@@ -9,7 +9,7 @@
9
9
  // reaches the agent's machine: git signs in to the relay with the agent's Quilt pass.
10
10
  import fs from 'node:fs'
11
11
  import path from 'node:path'
12
- import { spawn } from 'node:child_process'
12
+ import { spawn, spawnSync } from 'node:child_process'
13
13
  import { fileURLToPath } from 'node:url'
14
14
  import { runGit, syncStateOf } from './relay-git.js'
15
15
  import { upstreamsOf } from './relay-upstream.js'
@@ -61,13 +61,19 @@ export async function ensureView (dataDir, room, mirror) {
61
61
  export async function syncViewRefs (room, dir, repo) {
62
62
  const recs = upstreamsOf(room)
63
63
  const want = new Map()
64
+ // Several of the session's branches can follow one GitHub branch (branches started from main all
65
+ // follow main): the view's branch is the one of that name, else the default branch, else the first.
66
+ const rank = (key, rec) => (key === rec.ref ? 2 : key === room.defaultKey ? 1 : 0)
67
+ const ranked = new Map()
64
68
  for (const [key, rec] of Object.entries(recs)) {
65
69
  if (!rec || rec.repo !== repo || !rec.ref || rec.gone) continue
70
+ if (ranked.has(rec.ref) && ranked.get(rec.ref) >= rank(key, rec)) continue
66
71
  const st = room.meta.sync && room.meta.sync[key] ? syncStateOf(room, key) : null
67
72
  const sha = (st && st.base) || rec.sha
68
73
  if (!/^[0-9a-f]{40}$/.test(sha || '')) continue
69
74
  if (!(await runGit(dir, ['cat-file', '-e', `${sha}^{commit}`])).ok) continue
70
75
  want.set(rec.ref, sha)
76
+ ranked.set(rec.ref, rank(key, rec))
71
77
  }
72
78
  // Branches pushed through the view that the session doesn't follow (an agent's own): where GitHub took them.
73
79
  for (const [ref, sha] of Object.entries(room.meta.viewBranches || {})) {
@@ -82,13 +88,24 @@ export async function syncViewRefs (room, dir, repo) {
82
88
  return want
83
89
  }
84
90
 
91
+ /**
92
+ * Whether `git http-backend` runs here: it is a separate package on some systems (Alpine's git-daemon),
93
+ * and without it every request to /git/<room>.git would fail. Asked without a request, the CGI answers
94
+ * one of its own (a 500 with headers); a git without it says it has no such command and writes nothing.
95
+ */
96
+ export function gitHttpBackendWorks (git = 'git') {
97
+ const r = spawnSync(git, ['http-backend'], { env: { ...process.env, GIT_PROJECT_ROOT: '', REQUEST_METHOD: '' }, encoding: 'utf8', timeout: 10000 })
98
+ return !r.error && /^(Status|Content-Type|Expires):/im.test(r.stdout || '')
99
+ }
100
+
85
101
  /**
86
102
  * Serves one smart-HTTP request (`pathInfo`: /<room>.git/info/refs, …/git-upload-pack, …/git-receive-pack)
87
103
  * with git's own CGI, `git http-backend`, over `root` (the folder of view repositories). `env` reaches
88
- * the hooks of a push (who pushes, and how to ask the relay). Never throws; answers 500 when git fails.
104
+ * the hooks of a push (who pushes, and how to ask the relay). Never throws; answers 500 when git fails,
105
+ * and tells `onError` why (what git said), so the relay's log has it.
89
106
  */
90
- export function serveGit (req, res, { root, pathInfo, query = '', env = {} }) {
91
- const cgi = spawn('git', ['http-backend'], {
107
+ export function serveGit (req, res, { root, pathInfo, query = '', env = {}, git = 'git', onError = () => {} }) {
108
+ const cgi = spawn(git, ['http-backend'], {
92
109
  env: {
93
110
  ...process.env,
94
111
  ...env,
@@ -106,6 +123,7 @@ export function serveGit (req, res, { root, pathInfo, query = '', env = {} }) {
106
123
  })
107
124
  let head = Buffer.alloc(0)
108
125
  let headed = false
126
+ let said = ''
109
127
  cgi.stdout.on('data', (chunk) => {
110
128
  if (headed) return res.write(chunk)
111
129
  head = Buffer.concat([head, chunk])
@@ -121,12 +139,23 @@ export function serveGit (req, res, { root, pathInfo, query = '', env = {} }) {
121
139
  const v = line.slice(i + 1).trim()
122
140
  if (k === 'status') status = parseInt(v, 10) || 200; else headers[k] = v
123
141
  }
142
+ if (status >= 500) onError(`git http-backend answered ${status}`)
124
143
  res.writeHead(status, headers)
125
144
  res.write(head.subarray(at + 4))
126
145
  })
127
- cgi.stdout.on('end', () => { if (!headed) { res.writeHead(500); } res.end() })
128
- cgi.stderr.on('data', () => {})
129
- cgi.on('error', () => { if (!res.headersSent) res.writeHead(500); res.end() })
146
+ let done = false
147
+ cgi.stdout.on('end', () => { if (headed) res.end() })
148
+ // With no answer, wait for git to exit, not just for its stdout to end: what it said on stderr
149
+ // can come after (on a busy machine it did, and the log said "nothing said").
150
+ cgi.on('close', () => {
151
+ if (headed || done) return
152
+ done = true
153
+ onError(`git http-backend gave no answer: ${said.trim() || 'nothing said'}`)
154
+ if (!res.headersSent) res.writeHead(500)
155
+ res.end()
156
+ })
157
+ cgi.stderr.on('data', (chunk) => { if (said.length < 2000) said += chunk.toString() })
158
+ cgi.on('error', (err) => { if (done) return; done = true; onError(`git http-backend did not start: ${err.message}`); if (!res.headersSent) res.writeHead(500); res.end() })
130
159
  req.pipe(cgi.stdin)
131
160
  cgi.stdin.on('error', () => {})
132
161
  }
package/src/relay-mcp.js CHANGED
@@ -28,7 +28,7 @@ import { commitRequestMessage } from './commit-request.js'
28
28
  import { GIT_COMMIT_DESCRIPTION, gitCommitSchema, agentPrefix, slug } from './github-commit.js'
29
29
  import { liveSyncOn, agentCommitRefusal, ownFiles } from './relay-outbound.js'
30
30
  import { syncStatusText, SYNC_STATUS_DESCRIPTION } from './sync-words.js'
31
- import { clashAction, heldForClash, CLASH_DESCRIPTION, clashSchema } from './relay-clash.js'
31
+ import { clashAction, CLASH_DESCRIPTION, clashSchema } from './relay-clash.js'
32
32
  import { agentCommitsOf, repoRecordFor, POLICY_WORDS, pullRequestsFor, mergeForMember, describePulls, describeMerge, PULLS_DESCRIPTION, MERGE_DESCRIPTION, mergeSchema, agentsCanCommit, unpublishedFor, unpublishedRefusal, finishTaskFor, gitCommitFor, describeGitCommit, gitStatusFor, describeGitStatus, gitLogFor, describeGitLog, gitDiffFor, describeGitDiff, GIT_STATUS_DESCRIPTION, GIT_LOG_DESCRIPTION, GIT_DIFF_DESCRIPTION, GIT_REVERT_DESCRIPTION } from './relay-commit.js'
33
33
  import { UpdateCheck } from './update-check.js'
34
34
  import { TASK_WORKFLOW, pickupBrief, doneRefusal, verifiedEnough, verifiedLine, qaRefusal, qaNotesEnough, qaNotesLine, pickChecklist, MAX_VERIFIED } from './agent-task-workflow.js'
@@ -42,6 +42,7 @@ import { DEFAULT_KEY, validBranchKey } from './branchdocs.js'
42
42
  import { LOAD_FILES } from './relay-upstream.js'
43
43
  import { hostedRooms, hostedIn, newHosted } from './hosted-agents.js'
44
44
  import { turnSyncStep, ROOM_KEY } from './relay-turn-sync.js'
45
+ import { describeSecretNames, SECRET_NAMES_DESCRIPTION } from './secret-names.js'
45
46
 
46
47
  const FEED_CAP = 300
47
48
  const ACTIVITY_CAP = 300
@@ -90,7 +91,7 @@ export const HOSTED_INSTRUCTIONS =
90
91
  'HTTP is only for an agent with no computer. If you can run shell commands anywhere (a computer, a cloud machine, a sandbox), you must use the Quilt CLI there instead (`quilt agent join`, then `quilt join <invite> --agent <your name>`): files sync to disk and you show as live. ' +
91
92
  'A session has a branch for each git branch its members work on, each with its own files: quilt_status and quilt_branches list them and who is on each, and your first call on files puts you on the busiest one, where you stay until you pick another with quilt_git_branch (create: true loads a branch that is on GitHub but not yet in the session, at its latest commit; any other new branch starts from a copy of the files you have). ' +
92
93
  'Commits pushed or merged on GitHub come into your branch by themselves: through a member\'s folder on it when one is online and can fetch, otherwise the relay brings them in from GitHub about every 10 minutes. quilt_sync_branch asks for that now (at most once a minute per branch), for example after a PR merged. ' +
93
- 'When those commits clash with the session\'s uncommitted work, nothing is brought in and the relay gives one agent a task ("Bring N commits from origin/main into the session"), with each file and what changed upstream in its comments. If it is yours: for each file, quilt_read_file, fold in the upstream change while keeping the session\'s work, and quilt_write_file the merged file without conflict markers; each such write makes the relay look again at once, and the rest of the commits come in when every file merges. If you kept one side whole, move the task to QA once all files are done: the relay then takes the files as merged. The task closes itself. ' +
94
+ 'When those commits and the session\'s uncommitted work changed the same files, Quilt merges them on its own (where both changed the same lines, the newer edit stays, and whoever wrote the other lines is told): there is never a merge for you to do. ' +
94
95
  'A private repository needs a read-only GitHub token on the relay: when quilt_status says the relay can\'t read it, ask the session owner (quilt_github_token sets it, owner only). You have no folder here, so git is the quilt_git_* tools: taking a task (quilt_move_task to doing) puts you on a branch of your own, quilt/<you>/<task>; quilt_git_commit commits your changed files there and opens the pull request tied to the task; quilt_git_status says where you stand, quilt_git_diff what differs from GitHub, quilt_git_log what was committed, quilt_git_revert puts GitHub\'s version of a file back. The session\'s branch itself is open to agents only when the owner lets agents push to any branch. Work only in the session has not shipped: commit before you move a task to QA. Done merges the task\'s pull request once its checks pass: a person moves it to Done, or you when the owner lets agents commit to any branch. quilt_git_pull_requests lists open pull requests with their checks; quilt_git_merge merges one (agents: when the owner lets agents commit to any branch). When agents may not commit, ask a person with quilt_request_commit. Pushes and merges on GitHub reach the session within seconds. ' +
95
96
  'When a branch syncs with GitHub (quilt_git_status and quilt_branches say so), pushes and merges on GitHub come into the session by themselves, and nothing goes to GitHub until someone commits: quilt_git_commit commits your own changes (with_others: true takes others\' changes in the same files too, as co-authors). A file GitHub and the session both changed is held, and its merger proposes the merge with quilt_clash (the reviewer accepts). Releasing a file only lets go of your claim: it doesn\'t publish anything by itself. ' +
96
97
  TASK_WORKFLOW
@@ -824,7 +825,6 @@ function sessionTools (server, ctx) {
824
825
  description: CLASH_DESCRIPTION,
825
826
  inputSchema: clashSchema(z)
826
827
  }, async (args, { room, branch }) => {
827
- if (!room.mirrors || !room.cfg || !room.cfg.gitSync) return fail('This session doesn\'t sync live with GitHub, so there are no held clashes here.')
828
828
  try {
829
829
  const r = await clashAction(room, { who: { name: me, kind: ctx.kind === 'agent' ? 'agent' : 'human', access: ctx.access(room) }, key: keyOf(branch), action: args.action, path: args.path || '', content: args.content ?? null, checks: args.checks || null, force: args.force === true })
830
830
  return r.ok ? text(r.text) : fail(r.text)
@@ -1005,7 +1005,6 @@ function sessionTools (server, ctx) {
1005
1005
  if (a && a.role === 'viewer') return fail('You can only view this session; file changes are refused.')
1006
1006
  const refusal = a && changeRefusal(a, rel)
1007
1007
  if (refusal) return fail(`${refusal[0].toUpperCase()}${refusal.slice(1)}.`)
1008
- if (ctx.kind === 'agent' && room.meta.sync && heldForClash(room, branch ? branch.key : DEFAULT_KEY, rel)) return fail(`${rel} is held while a clash with GitHub is merged (quilt_clash status): propose the merged file with quilt_clash propose instead of writing it, so nothing half-merged reaches anyone.`)
1009
1008
  const claim = claimsOf(room).find((c) => c.by !== me && globMatcher(c.pattern)(rel))
1010
1009
  if (claim) return fail(heldRefusal(rel, claim))
1011
1010
  if (blobs.get(rel)?.stored) return fail(`${rel} is a large file kept in storage; it can't be changed here.`)
@@ -1158,7 +1157,7 @@ function sessionTools (server, ctx) {
1158
1157
  step: z.enum(['plan', 'push', 'pull', 'done']).describe('plan, then push, pull and done'),
1159
1158
  slot: z.string().min(8).max(64).describe('The slot in your QUILT_AGENT_KEY'),
1160
1159
  branch: z.string().max(200).optional().describe('plan: the folder\'s git branch; used when the session has it'),
1161
- files: z.record(z.string(), z.union([z.string(), z.object({ text: z.string().optional(), base64: z.string().optional(), deleted: z.boolean().optional() })])).optional().describe('plan and done: { path: sha1 }; push: { path: { text } | { base64 } | { deleted: true } }'),
1160
+ files: z.record(z.string(), z.union([z.string(), z.object({ text: z.string().optional(), base64: z.string().optional(), deleted: z.boolean().optional(), mtime: z.number().optional() })])).optional().describe('plan and done: { path: sha1 }; push: { path: { text, mtime } | { base64, mtime } | { deleted: true } } (mtime: when the folder last changed it, ms)'),
1162
1161
  paths: z.array(z.string().max(1024)).max(10000).optional().describe('pull: the paths to fetch')
1163
1162
  }
1164
1163
  }, (args, parts) => {
@@ -1192,7 +1191,6 @@ function sessionTools (server, ctx) {
1192
1191
  if (a && a.role === 'viewer') return { kind: 'aside', why: 'you can only view this session' }
1193
1192
  const scope = a && changeRefusal(a, rel)
1194
1193
  if (scope) return { kind: 'aside', why: scope }
1195
- if (ctx.kind === 'agent' && room.meta.sync && heldForClash(room, key, rel)) return { kind: 'keep', why: `${rel} is held while a clash with GitHub is merged` }
1196
1194
  const claim = claimsOf(room).find((c) => c.by !== me && globMatcher(c.pattern)(rel))
1197
1195
  return claim ? { kind: 'claimed', why: heldRefusal(rel, claim), by: claim.by } : null
1198
1196
  },
@@ -1335,7 +1333,7 @@ async function serve (mcp, req, res, body) {
1335
1333
  * saveHosted, log, endedMessage }. `workspaces`, given only while the API has them on and
1336
1334
  * the holder is an agent, is { apiUrl, pass (as sent), fetch }: the library tools are added.
1337
1335
  */
1338
- export async function handleHostedMcp ({ req, res, pass, relay, workspaces = null, body = undefined }) {
1336
+ export async function handleHostedMcp ({ req, res, pass, relay, workspaces = null, secrets = null, body = undefined }) {
1339
1337
  const image = String(req.headers['x-quilt-image'] || '').trim()
1340
1338
  // The library's guide is part of the instructions only when its tools are offered.
1341
1339
  const mcp = new McpServer({ name: 'quilt', version: '0.2.0' }, { instructions: workspaces ? `${HOSTED_INSTRUCTIONS}\n\n${WORKSPACE_GUIDE_HOSTED}` : HOSTED_INSTRUCTIONS })
@@ -1636,6 +1634,20 @@ export async function handleHostedMcp ({ req, res, pass, relay, workspaces = nul
1636
1634
  } catch (e) { return fail(e.message) }
1637
1635
  })
1638
1636
 
1637
+ // Secrets: names only. A hosted agent runs no commands, so it uses none (the answer says so).
1638
+ mcp.registerTool('quilt_secret_names', {
1639
+ description: `${SECRET_NAMES_DESCRIPTION} You are a hosted agent: you run no commands, so you can't use secrets yourself; this tells you which exist.`,
1640
+ inputSchema: { session: SESSION_ARG }
1641
+ }, async ({ session }) => {
1642
+ const c = current(session)
1643
+ if (c.error) return fail(c.error)
1644
+ if (!secrets) return text(describeSecretNames({ hosted: true }))
1645
+ try {
1646
+ const r = await hostedWorkspaceAccess(secrets).call('GET', `/v1/sessions/${encodeURIComponent(c.room.name)}/secret-names`)
1647
+ return text(describeSecretNames({ names: r.names, hosted: true }))
1648
+ } catch (e) { return fail(e.status === 503 ? 'Secrets are not switched on on this server yet.' : e.message) }
1649
+ })
1650
+
1639
1651
  sessionTools(mcp, ctx)
1640
1652
  // The library works whether or not the agent is in a session (it never touches relay.hosted).
1641
1653
  if (workspaces) registerWorkspaceTools(mcp, { ...hostedWorkspaceAccess(workspaces), webhook: false })
@@ -1,9 +1,12 @@
1
1
  // How busy and how big each room is, for placement (relay-cluster.js) and the relay's /stats:
2
2
  // WebSocket messages and bytes in and out, counted per minute; and the room's bytes in memory
3
3
  // and on disk. The accounts API reads them every minute and gives a heavy room a machine of its
4
- // own (src/api/relay-cluster.js), with heavyReasons deciding what heavy is.
4
+ // own (src/api/relay-cluster.js), with heavyReasons deciding what heavy is. The machine's own
5
+ // load (memory, event-loop lag, CPU) is MachineMeter's, for scaling the cluster on load.
5
6
  import fs from 'node:fs'
7
+ import os from 'node:os'
6
8
  import path from 'node:path'
9
+ import { monitorEventLoopDelay } from 'node:perf_hooks'
7
10
 
8
11
  export const METER_WINDOW_MS = 60 * 1000
9
12
  const zero = () => ({ msgsIn: 0, msgsOut: 0, bytesIn: 0, bytesOut: 0 })
@@ -73,3 +76,65 @@ export function heavyReasons (s, limits = HEAVY, busyMinutes = 0) {
73
76
  if (busyMinute(s, limits) && busyMinutes >= limits.ioMinutes) out.push('io')
74
77
  return out
75
78
  }
79
+
80
+ // How often the event loop is sampled for lag; every sample includes this much on its own.
81
+ export const LAG_RESOLUTION_MS = 20
82
+
83
+ /** The memory this machine (or its container's limit, when lower) has. */
84
+ export function machineMemory () {
85
+ const total = os.totalmem()
86
+ const limit = typeof process.constrainedMemory === 'function' ? Number(process.constrainedMemory()) || 0 : 0
87
+ return limit > 0 && limit < total ? limit : total
88
+ }
89
+
90
+ /**
91
+ * The machine's own load, a minute at a time: event-loop lag (the minute's 99th percentile and
92
+ * mean, in ms, beyond the sampling interval) and CPU (share of one core). `roll()` closes the
93
+ * minute (the report does, every minute); `last` is the last closed one.
94
+ */
95
+ export class MachineMeter {
96
+ constructor ({ now = Date.now, cpuUsage = (prev) => process.cpuUsage(prev), delay = null, resolutionMs = LAG_RESOLUTION_MS } = {}) {
97
+ this.now = now
98
+ this.cpuUsage = cpuUsage
99
+ this.resolutionMs = resolutionMs
100
+ this.delay = delay || monitorEventLoopDelay({ resolution: resolutionMs })
101
+ if (this.delay.enable) this.delay.enable()
102
+ this.at = now()
103
+ this.cpu0 = cpuUsage()
104
+ this.last = { lagMs: 0, lagMeanMs: 0, cpu: 0 }
105
+ }
106
+
107
+ roll () {
108
+ const t = this.now()
109
+ const used = this.cpuUsage(this.cpu0)
110
+ const wallUs = Math.max(1, t - this.at) * 1000
111
+ const ms = (ns) => Math.max(0, Math.round((Number(ns) || 0) / 1e6 - this.resolutionMs))
112
+ const sampled = (this.delay.count ?? 1) > 0
113
+ this.last = {
114
+ lagMs: sampled ? ms(this.delay.percentile(99)) : 0,
115
+ lagMeanMs: sampled ? ms(this.delay.mean) : 0,
116
+ cpu: Math.round(((used.user + used.system) / wallUs) * 1000) / 1000
117
+ }
118
+ this.delay.reset()
119
+ this.at = t
120
+ this.cpu0 = this.cpuUsage()
121
+ return this.last
122
+ }
123
+
124
+ stop () { if (this.delay.disable) this.delay.disable() }
125
+ }
126
+
127
+ /**
128
+ * A machine's load for the accounts API: memory used (the relay's resident memory) against the
129
+ * machine's, the last minute's event-loop lag and CPU, and every loaded room's traffic together.
130
+ */
131
+ export function machineLoad ({ meter, rooms = [], memTotal = machineMemory(), mem = process.memoryUsage() }) {
132
+ let bytesPerMin = 0
133
+ let msgsPerMin = 0
134
+ for (const r of rooms) {
135
+ bytesPerMin += (r.bytesIn || 0) + (r.bytesOut || 0)
136
+ msgsPerMin += (r.msgsIn || 0) + (r.msgsOut || 0)
137
+ }
138
+ const last = meter ? meter.last : { lagMs: 0, lagMeanMs: 0, cpu: 0 }
139
+ return { memUsed: mem.rss, memTotal, lagMs: last.lagMs, lagMeanMs: last.lagMeanMs, cpu: last.cpu, bytesPerMin, msgsPerMin }
140
+ }
@@ -3,8 +3,8 @@
3
3
  // files that differ from the branch's base commit become one commit on the base, built with git
4
4
  // plumbing in the mirror (the tree is the base's tree with the pending files applied, as git add
5
5
  // would store them), authored by whoever changed the most lines, co-authored by the rest, and
6
- // pushed forward only. Files a member holds, files in a clash, files with conflict markers and
7
- // paths git ignores never go in. Never a force-push: when the remote moved, inbound runs first
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
8
  // (relay-sync.js) and the commit is rebuilt on the new head.
9
9
  import fs from 'node:fs'
10
10
  import os from 'node:os'
@@ -12,12 +12,14 @@ import path from 'node:path'
12
12
  import crypto from 'node:crypto'
13
13
  import { syncStateOf, runGit, GitFailed, gcMirror, deleteRemoteBranch } from './relay-git.js'
14
14
  import { githubWriter } from './github-commit.js'
15
- import { bringInBranch, keepRefs, keptObjects, crashPoint, remoteHead } from './relay-sync.js'
16
- import { upstreamsOf, parseRepo, ignoreOf, hasMarkers, RELAY_ORIGIN } from './relay-upstream.js'
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
17
  import { gitBlobSha, slug, noreply, agentPrefix, othersInChange, othersEditing } from './github-commit.js'
18
18
  import { openPullOnPush, noteTaskRevision, agentCommitsOf } from './relay-commit.js'
19
- import { everyone, notePushedMerges } from './relay-clash.js'
20
- import { notePublished } from './upstream.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'
21
23
  import { unwrapAsRelay, decryptBlob } from './largefiles.js'
22
24
  import { lfsUpload } from './relay-lfs.js'
23
25
  import { isSafeRelPath, isIgnored, MAX_TEXT_BYTES, MAX_BINARY_BYTES } from './pathrules.js'
@@ -240,6 +242,38 @@ export async function storedBytes (room, e, rel) {
240
242
  return plain
241
243
  }
242
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
+
243
277
  /**
244
278
  * Builds the commit for branch `key` on `base`: the base's tree with every session file that
245
279
  * differs applied, and the deletions the session made since the base. Resolves
@@ -313,7 +347,15 @@ export async function buildCommit (room, key, { dir, base, message = '', by = ''
313
347
  // Stored large files (read by the relay) go up to GitHub's own limit; past it, or through filter=lfs, Git LFS.
314
348
  const limit = viaLfs ? Infinity : stored.has(rel) ? GITHUB_MAX_BYTES : (buf && buf.includes(0) ? MAX_BINARY_BYTES : MAX_TEXT_BYTES)
315
349
  if (buf && buf.length > limit) { (stored.has(rel) ? skipped.lfs : skipped.large).push(rel); continue }
316
- if (buf && !buf.includes(0) && hasMarkers(buf.toString('utf8'))) { skipped.markers.push(rel); continue }
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
+ }
317
359
  if (viaLfs) {
318
360
  // Through Git LFS: the bytes to GitHub's LFS store, the pointer in the commit.
319
361
  if (!lfs || !lfs.url) { skipped.lfs.push(rel); continue }
@@ -859,8 +901,16 @@ export function outboundFor (room) {
859
901
  if (to && room.postChat) room.postChat({ by: RELAY_BY, to, kind: 'nudge', text: `${rel} isn't pushed to GitHub: ${why}.` })
860
902
  }
861
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
+ }
862
911
  say(skipped.ignored, 'git ignores it (the repository\'s .gitignore, .quiltignore or Quilt\'s own list)')
863
- say(skipped.markers, 'it holds conflict markers; resolve them and it goes with the next commit')
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')
864
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')
865
915
  say(skipped.large, 'it is larger than Quilt syncs')
866
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')
@@ -885,10 +935,11 @@ export const MAX_BUNDLE_BYTES = 50 * 1024 * 1024
885
935
  * diverged): `bundle` is `git bundle create` of them. Each commit goes onto the branch's head on
886
936
  * GitHub with its own message and author: as it is when it already sits on the head (same commit,
887
937
  * same id), else replayed (git merge-tree, the same change on the new head). A commit whose change
888
- * GitHub already has (the relay pushed the session's copy of it) is skipped. Refused when a
889
- * commit's change clashes with GitHub's: the session merges that (relay-clash.js) and syncs it.
890
- * Resolves { state: 'pushed', sha, published: [{ original, published }], skipped: [shas] } or
891
- * { state: 'nothing' | 'clash' | 'refused', ... }.
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).
892
943
  */
893
944
  export async function replayBundle (room, key, { who, bundle, head, ref = '' }, { log = room.log || (() => {}) } = {}) {
894
945
  if (!liveSyncOn(room, key)) return { state: 'refused', why: 'this branch doesn\'t sync live with GitHub' }
@@ -929,8 +980,9 @@ export async function replayBundle (room, key, { who, bundle, head, ref = '' },
929
980
  * A folder's commits, in the mirror at ref `incoming` (ending at `head`), onto GitHub's head
930
981
  * `remoteHead` of the session branch `key`: each one on the head already goes as it is; one whose
931
982
  * change GitHub has already is skipped; any other is replayed with git merge-tree, keeping its author
932
- * and message. Then one fast-forward push. Resolves { state: 'pushed', sha, published, skipped },
933
- * 'nothing', 'clash' (with `at`), 'refused', 'protected' or 'moved'. Runs in the branch's queue.
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.
934
986
  */
935
987
  async function replayIncoming (room, key, { who, dir, incoming, head, remoteHead, token, log }) {
936
988
  const rec = upstreamsOf(room)[key]
@@ -939,22 +991,28 @@ async function replayIncoming (room, key, { who, dir, incoming, head, remoteHead
939
991
  {
940
992
  const list = (await runGit(dir, ['rev-list', '--reverse', '--topo-order', `${remoteHead}..${incoming}`])).out.split('\n').filter(Boolean)
941
993
  await runGit(dir, ['update-ref', '-d', incoming])
942
- if (!list.length) return { state: 'nothing', why: 'GitHub already has those commits' }
994
+ if (!list.length) return { state: 'nothing', head: remoteHead, why: 'GitHub already has those commits' }
943
995
  // On GitHub's head already (merge commits and all): they go as they are, a fast-forward.
944
996
  const ff = (await runGit(dir, ['merge-base', '--is-ancestor', remoteHead, head])).ok
945
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' }
946
998
  let tip = remoteHead
947
999
  const published = []
948
1000
  const skipped = []
1001
+ const merged = [] // files Quilt combined on the way: { path, lost, deleted, at: the local commit, github }
949
1002
  if (ff) { tip = head; for (const c of list) published.push({ original: c, published: c }) }
950
1003
  for (const c of ff ? [] : list) {
951
1004
  const parent = (await runGit(dir, ['rev-parse', `${c}^`])).out.trim()
952
1005
  if (parent === tip) { tip = c; published.push({ original: c, published: c }); continue } // already on the head: the commit itself
953
- const mt = await runGit(dir, ['merge-tree', '--write-tree', '--messages', `--merge-base=${parent}`, tip, c])
954
- const tree = mt.out.split('\n')[0].trim()
955
- if (!mt.ok || !/^[0-9a-f]{40}$/.test(tree)) {
956
- const files = mt.out.split('\n').filter((l) => /^CONFLICT/.test(l)).map((l) => l.replace(/^CONFLICT \([^)]*\): /, '')).slice(0, 5)
957
- return { state: 'clash', why: `your local commit ${c.slice(0, 7)} changes the same lines as GitHub${files.length ? ` (${files.join('; ')})` : ''}: the session holds and merges that file (quilt_clash), then it syncs`, at: c }
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)
958
1016
  }
959
1017
  const tipTree = (await runGit(dir, ['rev-parse', `${tip}^{tree}`])).out.trim()
960
1018
  if (tree === tipTree) { skipped.push(c); continue } // GitHub has this change already (the relay pushed the session's copy)
@@ -966,7 +1024,7 @@ async function replayIncoming (room, key, { who, dir, incoming, head, remoteHead
966
1024
  tip = made.out.trim()
967
1025
  published.push({ original: c, published: tip })
968
1026
  }
969
- if (tip === remoteHead) return { state: 'nothing', skipped, why: 'GitHub already has every change in those commits' }
1027
+ if (tip === remoteHead) return { state: 'nothing', head: remoteHead, skipped, why: 'GitHub already has every change in those commits' }
970
1028
  crashPoint(room, 'replay') // tests: the relay dies with the commits replayed, before pushing them
971
1029
  const r = await room.mirrors.push(dir, { branch: rec.ref, sha: tip, expected: remoteHead, token })
972
1030
  if (r.state !== 'accepted') return { state: r.state === 'protected' ? 'protected' : 'moved', why: r.why || 'GitHub moved meanwhile: try again' }
@@ -978,12 +1036,83 @@ async function replayIncoming (room, key, { who, dir, incoming, head, remoteHead
978
1036
  state.replays.push({ by: who.name, at: Date.now(), published, skipped })
979
1037
  state.replays = state.replays.slice(-50)
980
1038
  room.saveMeta(); room.broadcastBranches()
1039
+ if (merged.length) sessionTakesMerges(room, key, { who, rec, tip, remoteHead, merged })
981
1040
  if (room.tellPushed) room.tellPushed(key, tip)
982
- log(`[${room.name}] ${key}: pushed ${published.length} local commit${published.length === 1 ? '' : 's'} of ${who.name} (${tip.slice(0, 7)})${skipped.length ? `, ${skipped.length} already on GitHub` : ''}`)
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` : ''}`)
983
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' })
984
- return { state: 'pushed', sha: tip, published, skipped }
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 })
985
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) }
986
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)}` })
987
1116
  }
988
1117
 
989
1118
  /**
@@ -1047,9 +1176,9 @@ export async function receiveAgentPush (room, { who, name, updates, env }, { log
1047
1176
  return replayIncoming(room, key, { who: { name, quiet: true }, dir, incoming, head: u.new, remoteHead, token, log })
1048
1177
  })
1049
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 }
1050
- if (r.state !== 'pushed') return say(r.state === 'clash' ? `${r.why}. Fetch, merge GitHub's ${branch} into yours, and push again` : r.state === 'protected' ? `${branch} is protected on GitHub: push to a branch of your own and open a pull request` : r.why || `GitHub didn't take it (${r.state}): fetch and push again`)
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`)
1051
1180
  published.set(u.ref, r.sha)
1052
- lines.push(r.sha === u.new ? `${branch} on GitHub is at ${r.sha.slice(0, 7)}` : `${branch}: GitHub had moved, so your commits went on its head as ${r.sha.slice(0, 7)}; your folder follows it`)
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`)
1053
1182
  continue
1054
1183
  }
1055
1184
  // Any other branch (an agent's own, new or not yet in the session): straight to GitHub, fast-forward only.