heyquilt 0.0.1 → 0.3.90
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +85 -0
- package/README.md +630 -2
- package/RELEASES.md +733 -0
- package/assets/logo.svg +1 -0
- package/bin/quilt.js +816 -0
- package/desktop/main.js +315 -0
- package/package.json +163 -7
- package/src/account.js +563 -0
- package/src/admit-policy.js +32 -0
- package/src/agent-join.js +370 -0
- package/src/agent-task-workflow.js +176 -0
- package/src/agents/actions.js +85 -0
- package/src/agents/claude-code.js +180 -0
- package/src/agents/common.js +21 -0
- package/src/agents/cursor-transcripts.js +170 -0
- package/src/agents/cursor.js +404 -0
- package/src/agents/index.js +69 -0
- package/src/ai-vm-signin.js +98 -0
- package/src/ai-vm.js +380 -0
- package/src/api/access.js +75 -0
- package/src/api/activity.js +181 -0
- package/src/api/agent-auth.js +130 -0
- package/src/api/agent-placement.js +157 -0
- package/src/api/audit.js +83 -0
- package/src/api/auth.js +17 -0
- package/src/api/domains.js +30 -0
- package/src/api/file-paths.js +40 -0
- package/src/api/file-store.js +99 -0
- package/src/api/fly-machines.js +76 -0
- package/src/api/github-app.js +172 -0
- package/src/api/http.js +34 -0
- package/src/api/invite-email.js +53 -0
- package/src/api/issues.js +110 -0
- package/src/api/join-text.js +78 -0
- package/src/api/mailer.js +47 -0
- package/src/api/memory-store.js +1083 -0
- package/src/api/org-access.js +54 -0
- package/src/api/permissions.js +71 -0
- package/src/api/relay-cluster.js +251 -0
- package/src/api/relay-machines.js +60 -0
- package/src/api/routes/access-types.js +54 -0
- package/src/api/routes/agent-invites.js +115 -0
- package/src/api/routes/agents.js +157 -0
- package/src/api/routes/ai-vm.js +369 -0
- package/src/api/routes/github.js +380 -0
- package/src/api/routes/grants.js +70 -0
- package/src/api/routes/invites.js +188 -0
- package/src/api/routes/issues.js +47 -0
- package/src/api/routes/join.js +167 -0
- package/src/api/routes/members.js +110 -0
- package/src/api/routes/orgs.js +177 -0
- package/src/api/routes/relay-cluster.js +90 -0
- package/src/api/routes/relay.js +76 -0
- package/src/api/routes/session-invites.js +119 -0
- package/src/api/routes/sessions.js +92 -0
- package/src/api/routes/teams.js +132 -0
- package/src/api/routes/workspace-agents.js +332 -0
- package/src/api/routes/workspace-files.js +277 -0
- package/src/api/routes/workspace-invites.js +196 -0
- package/src/api/routes/workspaces.js +225 -0
- package/src/api/server.js +623 -0
- package/src/api/slugs.js +20 -0
- package/src/api/supabase-store.js +765 -0
- package/src/api/team-access.js +30 -0
- package/src/api/tokens.js +31 -0
- package/src/api/workspace-access.js +62 -0
- package/src/api/workspace-reach.js +60 -0
- package/src/autowork-tools.js +166 -0
- package/src/autowork.js +387 -0
- package/src/blobstore.js +99 -0
- package/src/boxes.js +37 -0
- package/src/branchdocs.js +235 -0
- package/src/branches.js +194 -0
- package/src/catchup.js +111 -0
- package/src/chat-archive.js +85 -0
- package/src/chat-links.js +923 -0
- package/src/clash.js +140 -0
- package/src/cloud-ai.js +50 -0
- package/src/commit-request.js +59 -0
- package/src/commit.js +213 -0
- package/src/connection.js +569 -0
- package/src/control.js +245 -0
- package/src/conversation.js +125 -0
- package/src/doctor.js +156 -0
- package/src/duties.js +303 -0
- package/src/editors.js +228 -0
- package/src/fsutil.js +87 -0
- package/src/git-hook.js +48 -0
- package/src/git.js +147 -0
- package/src/github-commit.js +320 -0
- package/src/gitignore.js +49 -0
- package/src/gitstate.js +900 -0
- package/src/history.js +307 -0
- package/src/hooks.js +371 -0
- package/src/hosted-agents.js +24 -0
- package/src/hosted-client.js +112 -0
- package/src/identity.js +99 -0
- package/src/inbox.js +237 -0
- package/src/integrations.js +221 -0
- package/src/key-cli.js +115 -0
- package/src/largefiles.js +91 -0
- package/src/legacy.js +25 -0
- package/src/mcp-proxy.js +40 -0
- package/src/mcp-room.js +32 -0
- package/src/mcp.js +1261 -0
- package/src/merge3.js +158 -0
- package/src/merges.js +148 -0
- package/src/pass-source.js +176 -0
- package/src/passes.js +64 -0
- package/src/pathrules.js +127 -0
- package/src/persona.js +101 -0
- package/src/presence.js +360 -0
- package/src/private-file.js +41 -0
- package/src/procs.js +70 -0
- package/src/project-rules.js +178 -0
- package/src/protocol.js +92 -0
- package/src/quiet-warnings.js +9 -0
- package/src/relay-ai-vm.js +82 -0
- package/src/relay-ask.js +141 -0
- package/src/relay-ci.js +111 -0
- package/src/relay-clash.js +597 -0
- package/src/relay-cluster.js +454 -0
- package/src/relay-commit.js +887 -0
- package/src/relay-filekey.js +46 -0
- package/src/relay-git.js +274 -0
- package/src/relay-githttp.js +137 -0
- package/src/relay-github.js +109 -0
- package/src/relay-lease.js +137 -0
- package/src/relay-lfs.js +37 -0
- package/src/relay-mcp.js +1679 -0
- package/src/relay-metrics.js +75 -0
- package/src/relay-outbound.js +1144 -0
- package/src/relay-sync.js +719 -0
- package/src/relay-turn-sync.js +307 -0
- package/src/relay-upstream.js +934 -0
- package/src/relay-webhooks.js +111 -0
- package/src/releases.js +131 -0
- package/src/report.js +129 -0
- package/src/runner.js +306 -0
- package/src/same-edit.js +18 -0
- package/src/server.js +4187 -0
- package/src/session-access.js +170 -0
- package/src/session-name.js +15 -0
- package/src/session.js +7361 -0
- package/src/settings.js +46 -0
- package/src/setup.js +184 -0
- package/src/shell-path.js +46 -0
- package/src/status.js +199 -0
- package/src/summarize.js +109 -0
- package/src/sync-words.js +42 -0
- package/src/task-comments.js +92 -0
- package/src/tasks.js +670 -0
- package/src/textdiff.js +20 -0
- package/src/turn-sync-plan.js +142 -0
- package/src/turn-sync.js +191 -0
- package/src/ui/access-form.js +37 -0
- package/src/ui/agent-env-key.js +76 -0
- package/src/ui/agent-guide.js +127 -0
- package/src/ui/agent-kinds.js +175 -0
- package/src/ui/agent-place.js +265 -0
- package/src/ui/app.css +2309 -0
- package/src/ui/app.js +542 -0
- package/src/ui/board.js +465 -0
- package/src/ui/boxes.js +76 -0
- package/src/ui/branches.js +111 -0
- package/src/ui/catchup.js +77 -0
- package/src/ui/changes.js +159 -0
- package/src/ui/chat.js +266 -0
- package/src/ui/cloud-ai-model.js +107 -0
- package/src/ui/cloud-ai.js +221 -0
- package/src/ui/common.js +431 -0
- package/src/ui/csv.js +21 -0
- package/src/ui/diffview.js +74 -0
- package/src/ui/feed-convs.js +39 -0
- package/src/ui/feed.js +174 -0
- package/src/ui/files.js +579 -0
- package/src/ui/fileview.js +123 -0
- package/src/ui/git-rule.js +60 -0
- package/src/ui/home.js +1323 -0
- package/src/ui/index.html +16 -0
- package/src/ui/invite.js +97 -0
- package/src/ui/invites.js +79 -0
- package/src/ui/loom-model.js +516 -0
- package/src/ui/loom.css +349 -0
- package/src/ui/loom.js +753 -0
- package/src/ui/mark.js +104 -0
- package/src/ui/merges.js +158 -0
- package/src/ui/rail-model.js +78 -0
- package/src/ui/rail.js +173 -0
- package/src/ui/releases.js +182 -0
- package/src/ui/schedule.js +156 -0
- package/src/ui/session.js +3394 -0
- package/src/ui/signin.js +75 -0
- package/src/ui/sounds.js +110 -0
- package/src/ui/tool-logo.js +86 -0
- package/src/ui/tree.js +276 -0
- package/src/ui/workspaces.js +542 -0
- package/src/ui-server.js +1272 -0
- package/src/update-check.js +67 -0
- package/src/upstream.js +155 -0
- package/src/webhooks.js +235 -0
- package/src/workspace-cli.js +62 -0
- package/src/workspace-tools.js +340 -0
- package/index.js +0 -2
package/src/git-hook.js
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// The hooks of the relay's git views (relay-githttp.js): git runs this for a push into a session's
|
|
3
|
+
// view, with the ref updates on stdin. The relay decides (QUILT_HOOK_URL, a secret only this relay
|
|
4
|
+
// process knows): before the refs move ("pre-receive") it checks the push and puts it on GitHub;
|
|
5
|
+
// after ("post-receive") it sets the view's branches back to what GitHub has. What the relay says is
|
|
6
|
+
// printed for the person or agent pushing ("remote: …").
|
|
7
|
+
import http from 'node:http'
|
|
8
|
+
import path from 'node:path'
|
|
9
|
+
|
|
10
|
+
// git gives hooks some paths relative to the repository (GIT_DIR=.): the relay needs them whole.
|
|
11
|
+
const whole = (p) => (p ? path.resolve(process.cwd(), p) : '')
|
|
12
|
+
const wholeList = (l) => String(l || '').split(path.delimiter).filter(Boolean).map(whole).join(path.delimiter)
|
|
13
|
+
|
|
14
|
+
const phase = process.argv[2] === 'post-receive' ? 'post' : 'pre'
|
|
15
|
+
let input = ''
|
|
16
|
+
process.stdin.setEncoding('utf8')
|
|
17
|
+
process.stdin.on('data', (d) => { input += d })
|
|
18
|
+
process.stdin.on('end', () => {
|
|
19
|
+
const url = process.env.QUILT_HOOK_URL
|
|
20
|
+
if (!url) { process.stderr.write('Quilt: this push did not come through the relay.\n'); process.exit(phase === 'pre' ? 1 : 0) }
|
|
21
|
+
const body = JSON.stringify({
|
|
22
|
+
phase,
|
|
23
|
+
room: process.env.QUILT_ROOM || '',
|
|
24
|
+
who: process.env.QUILT_WHO || '',
|
|
25
|
+
name: process.env.QUILT_WHO_NAME || '',
|
|
26
|
+
updates: input,
|
|
27
|
+
env: {
|
|
28
|
+
GIT_DIR: whole(process.env.GIT_DIR || '.'),
|
|
29
|
+
GIT_OBJECT_DIRECTORY: whole(process.env.GIT_OBJECT_DIRECTORY),
|
|
30
|
+
GIT_ALTERNATE_OBJECT_DIRECTORIES: wholeList(process.env.GIT_ALTERNATE_OBJECT_DIRECTORIES),
|
|
31
|
+
GIT_QUARANTINE_PATH: whole(process.env.GIT_QUARANTINE_PATH)
|
|
32
|
+
}
|
|
33
|
+
})
|
|
34
|
+
const req = http.request(url, { method: 'POST', headers: { 'content-type': 'application/json', 'content-length': Buffer.byteLength(body), authorization: `Bearer ${process.env.QUILT_HOOK_TOKEN || ''}` }, timeout: 10 * 60 * 1000 }, (res) => {
|
|
35
|
+
let out = ''
|
|
36
|
+
res.setEncoding('utf8')
|
|
37
|
+
res.on('data', (d) => { out += d })
|
|
38
|
+
res.on('end', () => {
|
|
39
|
+
let r = null
|
|
40
|
+
try { r = JSON.parse(out) } catch {}
|
|
41
|
+
if (r && r.message) process.stderr.write(`${r.message}\n`)
|
|
42
|
+
process.exit(r && r.ok ? 0 : (phase === 'pre' ? 1 : 0))
|
|
43
|
+
})
|
|
44
|
+
})
|
|
45
|
+
req.on('error', (err) => { process.stderr.write(`Quilt: the relay did not answer (${err.message}).\n`); process.exit(phase === 'pre' ? 1 : 0) })
|
|
46
|
+
req.on('timeout', () => req.destroy(new Error('it took too long')))
|
|
47
|
+
req.end(body)
|
|
48
|
+
})
|
package/src/git.js
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
// Git for starting a session from GitHub: checking names, listing repos and
|
|
2
|
+
// branches, cloning. Quilt never commits, pulls or pushes. Wraps the `git`
|
|
3
|
+
// and `gh` CLIs; arguments are always passed as a list, never through a shell.
|
|
4
|
+
import fs from 'node:fs'
|
|
5
|
+
import path from 'node:path'
|
|
6
|
+
import { execFile } from 'node:child_process'
|
|
7
|
+
|
|
8
|
+
// QUILT_GH lets tests stand in a fake `gh`.
|
|
9
|
+
const GH = () => process.env.QUILT_GH || 'gh'
|
|
10
|
+
const REPO_RE = /^[\w.-]+\/[\w.-]+$/
|
|
11
|
+
// Never stop to ask for a password or open an editor.
|
|
12
|
+
const ENV = { GIT_TERMINAL_PROMPT: '0', GH_PROMPT_DISABLED: '1', GIT_EDITOR: 'true', GH_NO_UPDATE_NOTIFIER: '1' }
|
|
13
|
+
|
|
14
|
+
/** Runs a command; resolves to stdout, or rejects with a short message from stderr. */
|
|
15
|
+
function run (cmd, args, { cwd, allowFail = false } = {}) {
|
|
16
|
+
return new Promise((resolve, reject) => {
|
|
17
|
+
execFile(cmd, args, { cwd, env: { ...process.env, ...ENV }, maxBuffer: 32 * 1024 * 1024, timeout: 5 * 60 * 1000 }, (err, stdout, stderr) => {
|
|
18
|
+
if (!err) return resolve(stdout)
|
|
19
|
+
if (allowFail) return resolve(null)
|
|
20
|
+
if (err.code === 'ENOENT') return reject(new Error(cmd === GH() ? 'The GitHub CLI (gh) is not installed.' : 'git is not installed.'))
|
|
21
|
+
const e = new Error(shortError(stderr || stdout || err.message))
|
|
22
|
+
e.stderr = String(stderr || '')
|
|
23
|
+
e.exitCode = err.code
|
|
24
|
+
reject(e)
|
|
25
|
+
})
|
|
26
|
+
})
|
|
27
|
+
}
|
|
28
|
+
const git = (dir, args, opts) => run('git', args, { cwd: dir, ...opts })
|
|
29
|
+
const gh = (args, opts) => run(GH(), args, opts)
|
|
30
|
+
|
|
31
|
+
/** The first useful line of a CLI error, without "fatal:"/"error:" noise. */
|
|
32
|
+
function shortError (text) {
|
|
33
|
+
const lines = String(text).split('\n').map((l) => l.trim()).filter(Boolean)
|
|
34
|
+
const line = lines.find((l) => /^(fatal|error|remote|GraphQL|HTTP)/i.test(l)) || lines[0] || 'Something went wrong.'
|
|
35
|
+
return line.replace(/^(fatal|error):\s*/i, '').replace(/^remote:\s*/i, '').slice(0, 300)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Throws unless `name` is a valid branch name. */
|
|
39
|
+
export async function checkBranchName (name) {
|
|
40
|
+
name = String(name || '').trim()
|
|
41
|
+
if (!name) throw new Error('Enter a branch name.')
|
|
42
|
+
// Not `--branch`: it expands @{-N} using the current folder's repo, so the
|
|
43
|
+
// answer would depend on where quilt happens to run.
|
|
44
|
+
if (name.startsWith('-') || name === 'HEAD' || (await run('git', ['check-ref-format', `refs/heads/${name}`], { allowFail: true })) === null) {
|
|
45
|
+
throw new Error(`"${name}" isn't a valid branch name.`)
|
|
46
|
+
}
|
|
47
|
+
return name
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function checkRepo (repo) {
|
|
51
|
+
repo = String(repo || '').trim()
|
|
52
|
+
if (!REPO_RE.test(repo) || repo.split('/').some((p) => /^\.+$/.test(p))) throw new Error('Pick a repository (owner/name).')
|
|
53
|
+
return repo
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// --------------------------------------------------------------- GitHub --
|
|
57
|
+
|
|
58
|
+
/** Is gh installed and signed in? { installed, authenticated, user, message } */
|
|
59
|
+
export async function ghStatus () {
|
|
60
|
+
const probe = (args) => new Promise((resolve) => {
|
|
61
|
+
execFile(GH(), args, { env: { ...process.env, ...ENV }, timeout: 20000 }, (err, stdout, stderr) => resolve({ err, text: `${stdout}\n${stderr}` }))
|
|
62
|
+
})
|
|
63
|
+
const version = await probe(['--version'])
|
|
64
|
+
if (version.err && version.err.code === 'ENOENT') return { installed: false, authenticated: false, user: null, message: 'Quilt can\'t list your GitHub repositories on this computer: start from a folder (a clone) instead.' }
|
|
65
|
+
const out = await probe(['auth', 'status', '--hostname', 'github.com'])
|
|
66
|
+
if (out.err) return { installed: true, authenticated: false, user: null, message: 'Quilt can\'t list your GitHub repositories on this computer: start from a folder (a clone) instead.' }
|
|
67
|
+
const user = (out.text.match(/account (\S+)/) || out.text.match(/as (\S+)/) || [])[1] || null
|
|
68
|
+
return { installed: true, authenticated: true, user, message: null }
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Your repos, plus your organizations' repos, most recently updated first. */
|
|
72
|
+
export async function listRepos ({ limit = 100 } = {}) {
|
|
73
|
+
limit = Math.max(1, Math.min(1000, Number(limit) || 100))
|
|
74
|
+
const fields = ['--json', 'nameWithOwner,description,updatedAt,defaultBranchRef']
|
|
75
|
+
const parse = (out) => { try { return JSON.parse(out || '[]') } catch { return [] } }
|
|
76
|
+
const mine = parse(await gh(['repo', 'list', ...fields, '--limit', String(limit)]))
|
|
77
|
+
const orgs = String(await gh(['api', 'user/orgs', '--jq', '.[].login'], { allowFail: true }) || '').split('\n').filter(Boolean).slice(0, 10)
|
|
78
|
+
const theirs = await Promise.all(orgs.map((o) => gh(['repo', 'list', o, ...fields, '--limit', String(Math.min(limit, 100))], { allowFail: true }).then(parse)))
|
|
79
|
+
const seen = new Set()
|
|
80
|
+
return [...mine, ...theirs.flat()]
|
|
81
|
+
.filter((r) => r && r.nameWithOwner && !seen.has(r.nameWithOwner) && seen.add(r.nameWithOwner))
|
|
82
|
+
.sort((a, b) => String(b.updatedAt || '').localeCompare(String(a.updatedAt || '')))
|
|
83
|
+
.map((r) => ({ name: r.nameWithOwner, description: r.description || '', updatedAt: r.updatedAt || null, defaultBranch: r.defaultBranchRef?.name || null }))
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Branch names of a GitHub repo, and which one is the default. */
|
|
87
|
+
export async function listBranches (repo) {
|
|
88
|
+
repo = checkRepo(repo)
|
|
89
|
+
const [names, def] = await Promise.all([
|
|
90
|
+
gh(['api', `repos/${repo}/branches`, '--paginate', '--jq', '.[].name']),
|
|
91
|
+
gh(['api', `repos/${repo}`, '--jq', '.default_branch'])
|
|
92
|
+
])
|
|
93
|
+
const defaultBranch = def.trim() || null
|
|
94
|
+
const branches = names.split('\n').map((s) => s.trim()).filter(Boolean)
|
|
95
|
+
// Default branch first, then alphabetical.
|
|
96
|
+
branches.sort((a, b) => (a === defaultBranch ? -1 : b === defaultBranch ? 1 : a.localeCompare(b)))
|
|
97
|
+
return { branches, defaultBranch }
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async function defaultBranchOf (dir) {
|
|
101
|
+
const head = await git(dir, ['symbolic-ref', '--quiet', '--short', 'refs/remotes/origin/HEAD'], { allowFail: true })
|
|
102
|
+
if (head && head.trim()) return head.trim().replace(/^origin\//, '')
|
|
103
|
+
for (const b of ['main', 'master']) {
|
|
104
|
+
if ((await git(dir, ['rev-parse', '--verify', '--quiet', `refs/remotes/origin/${b}`], { allowFail: true })) !== null) return b
|
|
105
|
+
}
|
|
106
|
+
return null
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Clones `repo` into `dir` (which must be new or empty), then checks out
|
|
111
|
+
* `branch`, or creates `newBranch` from origin/`base` (default: the default branch).
|
|
112
|
+
*/
|
|
113
|
+
export async function cloneRepo ({ repo, dir, branch, newBranch, base }) {
|
|
114
|
+
repo = checkRepo(repo)
|
|
115
|
+
if (!dir) throw new Error('Choose a folder to clone into.')
|
|
116
|
+
dir = path.resolve(dir)
|
|
117
|
+
if (branch) branch = await checkBranchName(branch)
|
|
118
|
+
if (newBranch) newBranch = await checkBranchName(newBranch)
|
|
119
|
+
if (base) base = await checkBranchName(base)
|
|
120
|
+
const existed = fs.existsSync(dir)
|
|
121
|
+
if (existed && fs.readdirSync(dir).some((f) => f !== '.DS_Store')) {
|
|
122
|
+
throw new Error(`${path.basename(dir)} already has files in it. Pick an empty or new folder.`)
|
|
123
|
+
}
|
|
124
|
+
fs.mkdirSync(path.dirname(dir), { recursive: true })
|
|
125
|
+
try {
|
|
126
|
+
await gh(['repo', 'clone', repo, dir])
|
|
127
|
+
const has = async (b) => (await git(dir, ['rev-parse', '--verify', '--quiet', `refs/remotes/origin/${b}`], { allowFail: true })) !== null
|
|
128
|
+
if (newBranch) {
|
|
129
|
+
base = base || await defaultBranchOf(dir)
|
|
130
|
+
if (!base || !(await has(base))) throw new Error(`There's no branch named ${base} on GitHub to start from.`)
|
|
131
|
+
if (await has(newBranch)) throw new Error(`A branch named ${newBranch} already exists. Use it instead, or pick another name.`)
|
|
132
|
+
await git(dir, ['checkout', '-q', '--no-track', '-b', newBranch, `origin/${base}`])
|
|
133
|
+
} else if (branch) {
|
|
134
|
+
const current = (await git(dir, ['branch', '--show-current'])).trim()
|
|
135
|
+
if (branch !== current) {
|
|
136
|
+
if (!(await has(branch))) throw new Error(`There's no branch named ${branch} on GitHub.`)
|
|
137
|
+
await git(dir, ['checkout', '-q', '-b', branch, '--track', `origin/${branch}`])
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return { dir, branch: (await git(dir, ['branch', '--show-current'])).trim() }
|
|
141
|
+
} catch (err) {
|
|
142
|
+
// Only clean up what we made.
|
|
143
|
+
if (!existed) fs.rmSync(dir, { recursive: true, force: true })
|
|
144
|
+
else for (const f of fs.readdirSync(dir)) if (f !== '.DS_Store') fs.rmSync(path.join(dir, f), { recursive: true, force: true })
|
|
145
|
+
throw err
|
|
146
|
+
}
|
|
147
|
+
}
|
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
// Commits made by the relay, through GitHub's API, for any member of a session: an agent on any
|
|
2
|
+
// tool (Codex, Cursor, Claude Code, a hosted agent over HTTP), with no git and no credentials on
|
|
3
|
+
// its machine, and with nobody else online. The session's copy of the files is the content; the
|
|
4
|
+
// branch's commit on GitHub is the parent; only the files named change. No folder, index or
|
|
5
|
+
// working tree is involved, so nobody's uncommitted work is swept in.
|
|
6
|
+
//
|
|
7
|
+
// It never rewrites history: a ref is only moved forward (GitHub refuses anything else without
|
|
8
|
+
// force, which is never sent), and a file that changed on GitHub since the session's copy was
|
|
9
|
+
// taken is refused rather than overwritten. Public and private repositories work alike: writing
|
|
10
|
+
// needs the session's GitHub token with write access to the repository's contents (and to pull
|
|
11
|
+
// requests, to open one).
|
|
12
|
+
//
|
|
13
|
+
// Git for this session (AGENT_COMMITS; docs/superpowers/specs/2026-10-17-git-for-everyone.md): off for
|
|
14
|
+
// everyone, or on with what agents may push: their own branches only (quilt/<agent>/…, the default),
|
|
15
|
+
// or any branch, the session's own included. People may push any branch while it is on.
|
|
16
|
+
import crypto from 'node:crypto'
|
|
17
|
+
import { blameChange } from './history.js'
|
|
18
|
+
|
|
19
|
+
const API = 'https://api.github.com'
|
|
20
|
+
export const AGENT_COMMITS = ['off', 'branches', 'any']
|
|
21
|
+
export const DEFAULT_AGENT_COMMITS = 'branches'
|
|
22
|
+
export const MAX_COMMIT_FILES = 300
|
|
23
|
+
export const MAX_COMMIT_BYTES = 20 * 1024 * 1024
|
|
24
|
+
const BRANCH = /^(?!\/)(?!.*\/\/)(?!.*\.\.)(?!.*\.lock(\/|$))(?!.*[\s~^:?*[\\\x00-\x1f\x7f])(?!.*@\{)[^]+(?<![/.])$/
|
|
25
|
+
|
|
26
|
+
export class CommitRefused extends Error {}
|
|
27
|
+
|
|
28
|
+
/** Who set the rule over a session, in words: "the workspace Platform", "the team Night shift", "the org Acme". */
|
|
29
|
+
export const ruleSetter = (set) => `the ${set.by === 'org' ? 'org' : set.by === 'team' ? 'team' : 'workspace'}${set.name ? ` ${set.name}` : ''}`
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Git for everyone, in one line, for whoever is in the session: `policy` ('off', 'branches', 'any'),
|
|
33
|
+
* `by` (the rule over the session, { by, name }, when one set it) and `login` (the GitHub account
|
|
34
|
+
* whose connection covers the repository, when one does).
|
|
35
|
+
*/
|
|
36
|
+
export function gitForEveryoneWords ({ policy = DEFAULT_AGENT_COMMITS, by = null, login = '' } = {}) {
|
|
37
|
+
if (policy === 'off') return `Git is off for everyone in this session: ${by ? ruleSetter(by) : 'the session owner'} turned it off.`
|
|
38
|
+
const agents = policy === 'any' ? 'people and agents push to any branch' : 'people push to any branch, agents to branches of their own'
|
|
39
|
+
if (login) return `Git is on for everyone here, through @${login}'s connection: ${agents}, with nothing to set up.`
|
|
40
|
+
return `Git is on for everyone here once someone connects GitHub (one click in the commit panel, by anyone whose GitHub account can write to the repository): ${agents}.`
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** git's id for a file's bytes (what GitHub's trees list), so content is compared without fetching it. */
|
|
44
|
+
export function gitBlobSha (buf) {
|
|
45
|
+
return crypto.createHash('sha1').update(`blob ${buf.length}\0`).update(buf).digest('hex')
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** "duncan", "daniel-carmichael": a name as a branch path segment. */
|
|
49
|
+
export function slug (s, max = 40) {
|
|
50
|
+
return String(s || '').toLowerCase().normalize('NFKD').replace(/[^\w\s-]/g, '').trim().replace(/[\s_]+/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '').slice(0, max).replace(/-$/, '') || 'work'
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** The branches an agent owns: quilt/<its name>/… */
|
|
54
|
+
export const agentPrefix = (name) => `quilt/${slug(name, 30)}/`
|
|
55
|
+
|
|
56
|
+
/** Whether `name` is a branch name git and GitHub accept. */
|
|
57
|
+
export const validBranchName = (name) => typeof name === 'string' && name.length > 0 && name.length <= 200 && BRANCH.test(name)
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Where a member's commit goes, or why it may not: { branch, own } or throws CommitRefused.
|
|
61
|
+
* `sessionBranch`: the GitHub branch the session's branch follows (main). `target`: what they
|
|
62
|
+
* asked for ('' = the default). `kind`: 'agent' for agents, anything else for people.
|
|
63
|
+
*/
|
|
64
|
+
export function commitTarget ({ policy = DEFAULT_AGENT_COMMITS, kind = 'human', name, sessionBranch, target = '', message = '' }) {
|
|
65
|
+
const agent = kind === 'agent'
|
|
66
|
+
const mode = AGENT_COMMITS.includes(policy) ? policy : DEFAULT_AGENT_COMMITS
|
|
67
|
+
if (agent && mode === 'off') throw new CommitRefused('Git is off for everyone in this session. Ask a person for the commit with quilt_request_commit (your files and a message).')
|
|
68
|
+
const prefix = agentPrefix(name)
|
|
69
|
+
let branch = String(target || '').trim().replace(/^refs\/heads\//, '')
|
|
70
|
+
if (!branch) branch = agent && mode === 'branches' ? `${prefix}${slug(message, 40)}` : sessionBranch
|
|
71
|
+
if (!validBranchName(branch)) throw new CommitRefused(`"${branch}" is not a branch name git accepts.`)
|
|
72
|
+
if (agent && mode === 'branches' && !branch.startsWith(prefix)) {
|
|
73
|
+
throw new CommitRefused(`The session owner lets agents commit only to branches of their own: ${prefix}<topic>${branch === sessionBranch ? ` (not ${sessionBranch} itself)` : ''}. Leave branch out to get one, and open a pull request with pull_request: true.`)
|
|
74
|
+
}
|
|
75
|
+
return { branch, own: branch !== sessionBranch }
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* What a commit of `files` would change: { changes: [{ path, sha?, deleted, bytes }], same: [paths], moved: [paths] }.
|
|
80
|
+
* `files`: Map path -> Buffer (the session's content) or null (deleted in the session).
|
|
81
|
+
* `parent`: Map path -> blob sha at the commit the new one goes on; `base`: at the commit the
|
|
82
|
+
* session's copy is measured from. A file whose blob differs between the two changed on GitHub
|
|
83
|
+
* since the session took its copy: it is `moved`, and committing the session's copy would undo
|
|
84
|
+
* that change. Without `base` (a branch only its agent writes to) nothing counts as moved.
|
|
85
|
+
*/
|
|
86
|
+
export function planCommit (files, { parent, base = null }) {
|
|
87
|
+
const changes = []
|
|
88
|
+
const same = []
|
|
89
|
+
const moved = []
|
|
90
|
+
for (const [rel, buf] of files) {
|
|
91
|
+
const now = buf ? gitBlobSha(buf) : null
|
|
92
|
+
const at = parent.get(rel) || null
|
|
93
|
+
if (now === at) { same.push(rel); continue }
|
|
94
|
+
if (base && (base.get(rel) || null) !== at) { moved.push(rel); continue }
|
|
95
|
+
changes.push({ path: rel, sha: now, deleted: !buf, bytes: buf || null })
|
|
96
|
+
}
|
|
97
|
+
return { changes, same, moved }
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Who changed each file in the session since `since` (ms) besides `me`, from the branch's
|
|
102
|
+
* history (entries oldest first): Map path -> [names]. Pulls from git are nobody's.
|
|
103
|
+
*/
|
|
104
|
+
export function othersEditing (paths, history, { me, since = 0 } = {}) {
|
|
105
|
+
const want = new Set(paths)
|
|
106
|
+
const out = new Map()
|
|
107
|
+
for (const e of history || []) {
|
|
108
|
+
if (!e || !want.has(e.path) || e.pulled || e.seed || !e.by || e.by === me || (e.ts || 0) <= since) continue
|
|
109
|
+
const list = out.get(e.path) || []
|
|
110
|
+
if (!list.includes(e.by)) list.push(e.by)
|
|
111
|
+
out.set(e.path, list)
|
|
112
|
+
}
|
|
113
|
+
return out
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Who besides `me` made the changes a commit of one text file would carry (history.js blameChange). */
|
|
117
|
+
export function othersInChange ({ before = '', after = '', entries = [], me }) {
|
|
118
|
+
return blameChange({ before, after, entries }).filter((n) => n !== me)
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** The commit message, and who made it: an agent is named as the author, the token's account commits it. */
|
|
122
|
+
export function messageFor ({ message, by, task = null, coAuthors = [] }) {
|
|
123
|
+
const lines = [String(message || '').trim() || 'Changes from the Quilt session']
|
|
124
|
+
const trailer = [`Made in Quilt by ${by}${task ? ` for task ${task.id}: ${task.title}` : ''}.`]
|
|
125
|
+
for (const n of coAuthors) trailer.push(`Co-authored-by: ${n} <${noreply(n)}>`)
|
|
126
|
+
return `${lines.join('\n')}\n\n${trailer.join('\n')}`
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export const noreply = (name) => `${slug(name, 40)}@agents.noreply.heyquilt.com`
|
|
130
|
+
|
|
131
|
+
class GitHubWriteError extends Error {
|
|
132
|
+
constructor (status, what, body) {
|
|
133
|
+
super(`GitHub answered ${status} to ${what}${body && body.message ? `: ${body.message}` : ''}`)
|
|
134
|
+
this.status = status
|
|
135
|
+
this.body = body
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** The GitHub calls a commit needs. The token goes in a header, never a URL. */
|
|
140
|
+
export function githubWriter ({ fetch = globalThis.fetch, token, owner, name }) {
|
|
141
|
+
const headers = { 'user-agent': 'quilt-relay', 'x-github-api-version': '2022-11-28', accept: 'application/vnd.github+json', authorization: `Bearer ${token}` }
|
|
142
|
+
const repo = `${API}/repos/${owner}/${name}`
|
|
143
|
+
const req = async (method, path, body, what) => {
|
|
144
|
+
const res = await fetch(`${repo}${path}`, { method, headers: { ...headers, ...(body ? { 'content-type': 'application/json' } : {}) }, ...(body ? { body: JSON.stringify(body) } : {}), redirect: 'follow' })
|
|
145
|
+
let json = null
|
|
146
|
+
try { json = await res.json() } catch {}
|
|
147
|
+
if (!res.ok) throw new GitHubWriteError(res.status, what, json)
|
|
148
|
+
return json
|
|
149
|
+
}
|
|
150
|
+
const enc = (ref) => ref.split('/').map(encodeURIComponent).join('/')
|
|
151
|
+
return {
|
|
152
|
+
/** The branch's head commit, or null when there is no such branch. */
|
|
153
|
+
async ref (branch) {
|
|
154
|
+
try { return (await req('GET', `/git/ref/heads/${enc(branch)}`, null, `branch ${branch}`)).object.sha } catch (err) { if (err.status === 404) return null; throw err }
|
|
155
|
+
},
|
|
156
|
+
/** A commit's tree sha and date. */
|
|
157
|
+
async commit (sha) {
|
|
158
|
+
const c = await req('GET', `/git/commits/${sha}`, null, `commit ${sha.slice(0, 7)}`)
|
|
159
|
+
return { tree: c.tree.sha, ts: Date.parse(c.committer && c.committer.date) || 0 }
|
|
160
|
+
},
|
|
161
|
+
/** Every file's blob sha in a tree: Map path -> sha (null when GitHub cut the listing short). */
|
|
162
|
+
async blobs (treeSha) {
|
|
163
|
+
const t = await req('GET', `/git/trees/${treeSha}?recursive=1`, null, 'the file list')
|
|
164
|
+
if (t.truncated) return null
|
|
165
|
+
return new Map((t.tree || []).filter((x) => x.type === 'blob').map((x) => [x.path, x.sha]))
|
|
166
|
+
},
|
|
167
|
+
async modes (treeSha) {
|
|
168
|
+
const t = await req('GET', `/git/trees/${treeSha}?recursive=1`, null, 'the file list')
|
|
169
|
+
return new Map((t.tree || []).filter((x) => x.type === 'blob').map((x) => [x.path, x.mode]))
|
|
170
|
+
},
|
|
171
|
+
/** A file's bytes at a commit, or null when it isn't there. */
|
|
172
|
+
async content (sha, rel) {
|
|
173
|
+
const res = await fetch(`${repo}/contents/${enc(rel)}?ref=${sha}`, { headers: { ...headers, accept: 'application/vnd.github.raw' }, redirect: 'follow' })
|
|
174
|
+
if (res.status === 404) return null
|
|
175
|
+
if (!res.ok) throw new GitHubWriteError(res.status, rel, null)
|
|
176
|
+
return Buffer.from(await res.arrayBuffer())
|
|
177
|
+
},
|
|
178
|
+
async blob (buf) { return (await req('POST', '/git/blobs', { content: buf.toString('base64'), encoding: 'base64' }, 'a file')).sha },
|
|
179
|
+
async tree (baseTree, entries) { return (await req('POST', '/git/trees', { base_tree: baseTree, tree: entries }, 'the new file list')).sha },
|
|
180
|
+
async newCommit ({ message, tree, parent, author }) {
|
|
181
|
+
return (await req('POST', '/git/commits', { message, tree, parents: [parent], ...(author ? { author } : {}) }, 'the commit')).sha
|
|
182
|
+
},
|
|
183
|
+
/** Moves the branch forward to `sha` (never forced: GitHub refuses a move that would lose commits). */
|
|
184
|
+
async move (branch, sha) { await req('PATCH', `/git/refs/heads/${enc(branch)}`, { sha, force: false }, `branch ${branch}`) },
|
|
185
|
+
async create (branch, sha) { await req('POST', '/git/refs', { ref: `refs/heads/${branch}`, sha }, `new branch ${branch}`) },
|
|
186
|
+
async pull ({ title, head, base, body }) {
|
|
187
|
+
try {
|
|
188
|
+
const p = await req('POST', '/pulls', { title, head, base, body }, 'the pull request')
|
|
189
|
+
return { number: p.number, url: p.html_url }
|
|
190
|
+
} catch (err) {
|
|
191
|
+
// One is already open for this branch: say which.
|
|
192
|
+
if (err.status === 422) {
|
|
193
|
+
const open = await req('GET', `/pulls?head=${encodeURIComponent(`${owner}:${head}`)}&state=open`, null, 'pull requests')
|
|
194
|
+
if (Array.isArray(open) && open[0]) return { number: open[0].number, url: open[0].html_url, existed: true }
|
|
195
|
+
}
|
|
196
|
+
throw err
|
|
197
|
+
}
|
|
198
|
+
},
|
|
199
|
+
/** The branch's newest commits: [{ sha, message, author, date, url }]. */
|
|
200
|
+
async log (branch, n = 10) {
|
|
201
|
+
const list = await req('GET', `/commits?sha=${encodeURIComponent(branch)}&per_page=${Math.max(1, Math.min(50, n | 0))}`, null, `commits on ${branch}`)
|
|
202
|
+
return (Array.isArray(list) ? list : []).map((c) => ({ sha: c.sha, message: (c.commit && c.commit.message) || '', author: (c.commit && c.commit.author && c.commit.author.name) || (c.author && c.author.login) || '', date: (c.commit && c.commit.author && c.commit.author.date) || '', url: c.html_url }))
|
|
203
|
+
},
|
|
204
|
+
/** Open pull requests, newest first: [{ number, title, head, base, url, draft, by, sha }]. */
|
|
205
|
+
async pulls () {
|
|
206
|
+
const list = await req('GET', '/pulls?state=open&per_page=50&sort=updated&direction=desc', null, 'pull requests')
|
|
207
|
+
return (Array.isArray(list) ? list : []).map((p) => ({ number: p.number, title: p.title, head: p.head && p.head.ref, base: p.base && p.base.ref, url: p.html_url, draft: !!p.draft, by: p.user && p.user.login, sha: p.head && p.head.sha }))
|
|
208
|
+
},
|
|
209
|
+
/** One pull request: { number, title, head, base, url, sha, state, merged, mergeable, mergeableState }. */
|
|
210
|
+
async pullRequest (number) {
|
|
211
|
+
const p = await req('GET', `/pulls/${Number(number)}`, null, `pull request #${number}`)
|
|
212
|
+
return { number: p.number, title: p.title, head: p.head && p.head.ref, base: p.base && p.base.ref, url: p.html_url, sha: p.head && p.head.sha, state: p.state, merged: !!p.merged, mergeable: p.mergeable, mergeableState: p.mergeable_state || '' }
|
|
213
|
+
},
|
|
214
|
+
/** How `head` stands to `base`: 'identical', 'ahead' (base is in head), 'behind' or 'diverged' (it isn't). */
|
|
215
|
+
async compare (base, head) {
|
|
216
|
+
const c = await req('GET', `/compare/${encodeURIComponent(base)}...${encodeURIComponent(head)}`, null, 'a comparison')
|
|
217
|
+
return (c && c.status) || 'diverged'
|
|
218
|
+
},
|
|
219
|
+
/** How a commit's checks stand: 'passing', 'failing', 'pending' or 'none', with the failing ones named. */
|
|
220
|
+
async checks (sha) {
|
|
221
|
+
const runs = await req('GET', `/commits/${sha}/check-runs?per_page=100`, null, 'checks')
|
|
222
|
+
const st = await req('GET', `/commits/${sha}/status`, null, 'statuses')
|
|
223
|
+
const items = (runs && Array.isArray(runs.check_runs) ? runs.check_runs : []).map((c) => ({ name: c.name, done: c.status === 'completed', ok: ['success', 'neutral', 'skipped'].includes(c.conclusion) }))
|
|
224
|
+
for (const s of (st && Array.isArray(st.statuses) ? st.statuses : [])) items.push({ name: s.context, done: s.state !== 'pending', ok: s.state === 'success' })
|
|
225
|
+
if (!items.length) return { state: 'none', failing: [] }
|
|
226
|
+
const failing = items.filter((c) => c.done && !c.ok).map((c) => c.name)
|
|
227
|
+
if (failing.length) return { state: 'failing', failing }
|
|
228
|
+
return { state: items.every((c) => c.done) ? 'passing' : 'pending', failing: [] }
|
|
229
|
+
},
|
|
230
|
+
/** Merges a pull request: { sha }. GitHub refuses when it can't be merged as it is (conflicts, rules). */
|
|
231
|
+
async merge (number, { method = 'squash', title = '' } = {}) {
|
|
232
|
+
const r = await req('PUT', `/pulls/${Number(number)}/merge`, { merge_method: method, ...(title ? { commit_title: title } : {}) }, `merging #${number}`)
|
|
233
|
+
return { sha: r.sha, merged: !!r.merged }
|
|
234
|
+
},
|
|
235
|
+
async deleteBranch (branch) {
|
|
236
|
+
try { await req('DELETE', `/git/refs/heads/${enc(branch)}`, null, `deleting ${branch}`) } catch (err) { if (err.status !== 404 && err.status !== 422) throw err }
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** Why GitHub refused, in words for whoever asked (never the token). */
|
|
242
|
+
export function writeProblem (err, repo) {
|
|
243
|
+
const s = err && err.status
|
|
244
|
+
if (s === 401) return `the session's GitHub token was refused by GitHub (expired or revoked): ask the session owner for a new one (quilt_github_token)`
|
|
245
|
+
if (s === 403 || s === 404) return `the session's GitHub token can't write to ${repo}: the owner needs a token with "Contents: Read and write" on it (and "Pull requests: Read and write" to open pull requests), set with quilt_github_token`
|
|
246
|
+
if (s === 409) return `${repo} is empty or being changed: try again`
|
|
247
|
+
if (s === 422) return `GitHub refused: ${err.body && err.body.message ? err.body.message : 'the branch moved meanwhile'}`
|
|
248
|
+
return err && err.message ? err.message : String(err)
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Makes the commit on GitHub: `files` (Map path -> Buffer|null) onto `branch`, which starts at
|
|
253
|
+
* `from` when it doesn't exist yet (the session's commit), checked against `base` (the commit
|
|
254
|
+
* the session's copy is measured from; null for a branch of the asker's own, which only they write to). Resolves { sha, branch, created, changed, same } or
|
|
255
|
+
* throws CommitRefused (nothing to commit, files changed on GitHub) or a GitHub error.
|
|
256
|
+
*/
|
|
257
|
+
export async function commitOnGitHub (gh, { files, branch, from, base, message, author, tries = 2 }) {
|
|
258
|
+
for (let attempt = 1; ; attempt++) {
|
|
259
|
+
const head = await gh.ref(branch)
|
|
260
|
+
const parent = head || from
|
|
261
|
+
const pc = await gh.commit(parent)
|
|
262
|
+
const parentBlobs = await gh.blobs(pc.tree)
|
|
263
|
+
if (!parentBlobs) throw new CommitRefused('The repository is too large for GitHub to list in one go; commit from a folder with git.')
|
|
264
|
+
const baseBlobs = !base ? null : base === parent ? parentBlobs : await gh.blobs((await gh.commit(base)).tree)
|
|
265
|
+
if (base && !baseBlobs) throw new CommitRefused('The repository is too large for GitHub to list in one go; commit from a folder with git.')
|
|
266
|
+
const plan = planCommit(files, { parent: parentBlobs, base: baseBlobs })
|
|
267
|
+
if (plan.moved.length) {
|
|
268
|
+
throw new CommitRefused(`${plan.moved.join(', ')} changed on ${head ? branch : 'GitHub'} since the session's copy was taken, so committing the session's copy would undo that. ` +
|
|
269
|
+
'Bring those commits into the session first (quilt_sync_branch; the session then has both), then commit again.')
|
|
270
|
+
}
|
|
271
|
+
if (!plan.changes.length) return { sha: parent, branch, created: false, changed: [], same: plan.same, nothing: true, exists: !!head }
|
|
272
|
+
const modes = await gh.modes(pc.tree)
|
|
273
|
+
const entries = []
|
|
274
|
+
for (const c of plan.changes) {
|
|
275
|
+
if (c.deleted) entries.push({ path: c.path, mode: modes.get(c.path) || '100644', type: 'blob', sha: null })
|
|
276
|
+
else entries.push({ path: c.path, mode: modes.get(c.path) || '100644', type: 'blob', sha: await gh.blob(c.bytes) })
|
|
277
|
+
}
|
|
278
|
+
const tree = await gh.tree(pc.tree, entries)
|
|
279
|
+
const sha = await gh.newCommit({ message, tree, parent, author })
|
|
280
|
+
try {
|
|
281
|
+
if (head) await gh.move(branch, sha)
|
|
282
|
+
else await gh.create(branch, sha)
|
|
283
|
+
return { sha, parent, branch, created: !head, changed: plan.changes.map((c) => c.path), same: plan.same }
|
|
284
|
+
} catch (err) {
|
|
285
|
+
// Someone pushed to the branch meanwhile: start again from its new head.
|
|
286
|
+
if ((err.status === 422 || err.status === 409) && attempt < tries) continue
|
|
287
|
+
throw err
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/** quilt_commit, the same for every agent (local MCP, hosted MCP, CLI). */
|
|
293
|
+
export const GIT_COMMIT_DESCRIPTION = 'Commit your work to the branch you are on, on GitHub: the session\'s copy of exactly these files (by default every change of yours on record on this branch), with your message. ' +
|
|
294
|
+
'On a branch of your own (quilt/<you>/…, where taking a task puts you) the pull request to the session\'s branch opens by itself and is tied to your task. ' +
|
|
295
|
+
'The session\'s branch itself is open to agents only when the owner lets agents push to any branch; otherwise take a task or quilt_git_branch create first. ' +
|
|
296
|
+
'Refused for a file that changed on GitHub since the session\'s copy (quilt_sync_branch first) and for one holding others\' uncommitted changes (unless with_others). ' +
|
|
297
|
+
'Commit as soon as work is finished and tested: work that is only in the session has not shipped. When the owner keeps agents from committing, ask a person with quilt_request_commit.'
|
|
298
|
+
export const gitCommitSchema = (z) => ({
|
|
299
|
+
message: z.string().min(1).max(2000).describe('What the change does (the first line is the subject)'),
|
|
300
|
+
files: z.array(z.string().min(1).max(500)).max(200).optional().describe('The files to commit, deleted ones too; default: every change of yours on record on this branch'),
|
|
301
|
+
task: z.string().max(40).optional().describe('The task this is for: its title names the pull request, and the task follows it'),
|
|
302
|
+
with_others: z.boolean().optional().describe('Also commit files that hold other people\'s uncommitted changes (they are named as co-authors)')
|
|
303
|
+
})
|
|
304
|
+
|
|
305
|
+
export const COMMIT_DESCRIPTION = 'For agents with no folder (hosted, over HTTP): commit your finished work to the repository on GitHub through the relay, since you have no git of your own; nobody else needs to be online. Agents with a folder push with git instead. ' +
|
|
306
|
+
'Quilt commits the session\'s copy of exactly these files (by default every change of yours on record on this branch) with your message, and everyone sees it in chat. ' +
|
|
307
|
+
'Where it goes is the session owner\'s call: by default a branch of your own (quilt/<you>/<topic>; pull_request: true opens a pull request to the session\'s branch); ' +
|
|
308
|
+
'when the owner lets agents commit to any branch, leaving branch out commits to the session\'s branch itself. ' +
|
|
309
|
+
'Refused for a file that changed on GitHub since the session\'s copy (bring it in with quilt_sync_branch first) and for one holding others\' uncommitted changes (unless with_others). ' +
|
|
310
|
+
'Commit as soon as work is finished and tested: work that is only in the session has not shipped. When the owner keeps agents from committing, ask a person with quilt_request_commit.'
|
|
311
|
+
|
|
312
|
+
/** quilt_commit's arguments, the same for the local and the hosted tool (`z`: zod). */
|
|
313
|
+
export const commitSchema = (z) => ({
|
|
314
|
+
message: z.string().max(2000).optional().describe('The commit message: what the change does (a task\'s title by default)'),
|
|
315
|
+
files: z.array(z.string().max(1024)).max(300).optional().describe('The files to commit, relative to the project\'s top folder (deleted ones too); default: every change of yours on record on this branch'),
|
|
316
|
+
branch: z.string().max(200).optional().describe('The GitHub branch to commit to; default: a branch of your own (quilt/<you>/<topic>), or the session\'s branch when the owner lets agents commit there'),
|
|
317
|
+
pull_request: z.boolean().optional().describe('Open a pull request from your branch to the session\'s branch'),
|
|
318
|
+
with_others: z.boolean().optional().describe('Also commit files that hold others\' uncommitted changes (they are named as co-authors)'),
|
|
319
|
+
task: z.string().max(40).optional().describe('The task this work was for: its title is the message and its changes the files, unless you give them')
|
|
320
|
+
})
|
package/src/gitignore.js
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// Quilt keeps a session's local state in .quilt/. In a git folder that is an
|
|
2
|
+
// untracked folder, so `git stash -u` or `git clean -fd` would take it away. The
|
|
3
|
+
// project's own .gitignore is where git is told to leave it alone: a line is
|
|
4
|
+
// added there (and shared, like any edit of .gitignore). Nothing under .git is
|
|
5
|
+
// ever touched.
|
|
6
|
+
import fs from 'node:fs'
|
|
7
|
+
import path from 'node:path'
|
|
8
|
+
import { gitDir } from './gitstate.js'
|
|
9
|
+
|
|
10
|
+
const IGNORES_QUILT = new Set(['.quilt', '.quilt/', '/.quilt', '/.quilt/'])
|
|
11
|
+
export const QUILT_IGNORE_COMMENT = "# Quilt keeps this session's local state here"
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Whether .gitignore's text already has a line ignoring .quilt. Trailing
|
|
15
|
+
* spaces don't count (git drops them); leading ones are part of the pattern.
|
|
16
|
+
*/
|
|
17
|
+
export function ignoresQuilt (text) {
|
|
18
|
+
return String(text).split(/\r?\n/).some((line) => IGNORES_QUILT.has(line.replace(/\s+$/, '')))
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* In a git folder, makes sure .gitignore ignores .quilt/: appends a comment
|
|
23
|
+
* and `.quilt/` when no line does (creating the file if need be, in its own
|
|
24
|
+
* line endings). Returns { added: true }, { added: false } (nothing to do:
|
|
25
|
+
* not a git folder, or already ignored), or { added: false, error } when
|
|
26
|
+
* .gitignore couldn't be read or written.
|
|
27
|
+
*/
|
|
28
|
+
export function ensureQuiltIgnored (root) {
|
|
29
|
+
if (!gitDir(root)) return { added: false }
|
|
30
|
+
const file = path.join(root, '.gitignore')
|
|
31
|
+
let text = ''
|
|
32
|
+
try {
|
|
33
|
+
const st = fs.lstatSync(file)
|
|
34
|
+
if (!st.isFile()) return { added: false, error: new Error('.gitignore is not a plain file') }
|
|
35
|
+
text = fs.readFileSync(file, 'utf8')
|
|
36
|
+
} catch (err) {
|
|
37
|
+
if (err.code !== 'ENOENT') return { added: false, error: err }
|
|
38
|
+
}
|
|
39
|
+
if (ignoresQuilt(text)) return { added: false }
|
|
40
|
+
const eol = text.includes('\r\n') ? '\r\n' : '\n'
|
|
41
|
+
const lead = text && !text.endsWith('\n') ? eol : ''
|
|
42
|
+
try {
|
|
43
|
+
// Appended, so the file's own bytes stay as they are; O_NOFOLLOW: never through a link put there meanwhile.
|
|
44
|
+
fs.writeFileSync(file, `${lead}${QUILT_IGNORE_COMMENT}${eol}.quilt/${eol}`, { flag: fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_APPEND | (fs.constants.O_NOFOLLOW || 0) })
|
|
45
|
+
} catch (err) {
|
|
46
|
+
return { added: false, error: err }
|
|
47
|
+
}
|
|
48
|
+
return { added: true }
|
|
49
|
+
}
|