heyquilt 0.3.90 → 0.3.107
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/README.md +14 -11
- package/RELEASES.md +135 -2
- package/bin/quilt.js +45 -12
- package/package.json +3 -3
- package/src/account.js +28 -0
- package/src/agents/claude-code.js +11 -4
- package/src/agents/cursor-transcripts.js +11 -4
- package/src/ai-vm-signin.js +14 -2
- package/src/ai-vm.js +150 -8
- package/src/api/agent-auth.js +9 -3
- package/src/api/fly-machines.js +72 -15
- package/src/api/memory-store.js +142 -6
- package/src/api/relay-cluster.js +337 -43
- package/src/api/relay-machines.js +35 -3
- package/src/api/releases.js +87 -0
- package/src/api/routes/agents.js +111 -16
- package/src/api/routes/ai-vm.js +146 -19
- package/src/api/routes/orgs.js +3 -1
- package/src/api/routes/secrets.js +448 -0
- package/src/api/routes/workspaces.js +8 -1
- package/src/api/sandbox-keys.js +57 -0
- package/src/api/secrets-access.js +93 -0
- package/src/api/server.js +40 -9
- package/src/api/supabase-store.js +121 -11
- package/src/autowork.js +3 -15
- package/src/catchup.js +1 -1
- package/src/clash.js +10 -22
- package/src/control.js +18 -15
- package/src/fsutil.js +11 -1
- package/src/gitstate.js +29 -5
- package/src/hooks.js +1 -1
- package/src/mcp-proxy.js +7 -2
- package/src/mcp.js +22 -25
- package/src/merge3.js +94 -12
- package/src/pass-source.js +9 -2
- package/src/pathrules.js +7 -0
- package/src/relay-clash.js +101 -484
- package/src/relay-githttp.js +36 -7
- package/src/relay-mcp.js +19 -7
- package/src/relay-metrics.js +66 -1
- package/src/relay-outbound.js +154 -25
- package/src/relay-sync.js +133 -136
- package/src/relay-turn-sync.js +250 -51
- package/src/relay-upstream.js +49 -179
- package/src/releases.js +16 -9
- package/src/runner.js +3 -2
- 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 +71 -44
- package/src/session.js +347 -262
- package/src/status.js +3 -5
- package/src/sync-words.js +1 -1
- package/src/turn-sync-plan.js +13 -10
- package/src/turn-sync.js +32 -11
- package/src/ui/agent-guide.js +4 -4
- package/src/ui/app.css +208 -23
- package/src/ui/app.js +4 -0
- package/src/ui/cloud-ai-model.js +3 -2
- package/src/ui/common.js +2 -0
- package/src/ui/fileview.js +28 -1
- package/src/ui/home.js +32 -33
- package/src/ui/loom.css +18 -2
- package/src/ui/loom.js +41 -8
- package/src/ui/rail-model.js +6 -9
- package/src/ui/releases.js +1 -1
- package/src/ui/sandbox-keys-model.js +53 -0
- package/src/ui/sandbox-keys.js +132 -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 +47 -149
- package/src/ui/tree.js +23 -18
- package/src/ui/workspaces.js +5 -0
- package/src/ui-server.js +140 -28
- package/src/update-check.js +1 -1
- package/src/upstream.js +75 -38
- 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/ui/merges.js +0 -158
package/src/relay-githttp.js
CHANGED
|
@@ -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(
|
|
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
|
-
|
|
128
|
-
cgi.
|
|
129
|
-
|
|
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,
|
|
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
|
|
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 })
|
package/src/relay-metrics.js
CHANGED
|
@@ -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
|
+
}
|
package/src/relay-outbound.js
CHANGED
|
@@ -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
|
|
7
|
-
//
|
|
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'))) {
|
|
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
|
|
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.
|
|
889
|
-
*
|
|
890
|
-
* Resolves { state: 'pushed', sha, published: [{ original, published }], skipped: [shas] } or
|
|
891
|
-
* { state: 'nothing' | '
|
|
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
|
|
933
|
-
* '
|
|
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
|
-
|
|
955
|
-
if (
|
|
956
|
-
|
|
957
|
-
|
|
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 === '
|
|
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.
|