heyquilt 0.3.138 → 0.3.141

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/RELEASES.md +10 -0
  2. package/bin/quilt.js +7 -143
  3. package/package.json +1 -1
  4. package/src/account.js +0 -29
  5. package/src/api/memory-store.js +0 -29
  6. package/src/api/routes/teams.js +0 -23
  7. package/src/api/server.js +5 -19
  8. package/src/api/supabase-store.js +0 -52
  9. package/src/branch-view.js +6 -131
  10. package/src/control.js +2 -33
  11. package/src/duties.js +1 -1
  12. package/src/github-commit.js +3 -315
  13. package/src/mcp.js +8 -157
  14. package/src/relay-ask.js +3 -66
  15. package/src/relay-cluster.js +3 -5
  16. package/src/relay-mcp.js +6 -418
  17. package/src/relay-prune.js +6 -145
  18. package/src/relay-turn-sync.js +1 -1
  19. package/src/server.js +33 -1331
  20. package/src/session-follow.js +18 -5
  21. package/src/session.js +58 -3526
  22. package/src/status.js +2 -58
  23. package/src/turn-sync.js +15 -2
  24. package/src/ui/changes.js +5 -14
  25. package/src/ui/chat.js +0 -1
  26. package/src/ui/common.js +0 -2
  27. package/src/ui/git-rule.js +2 -30
  28. package/src/ui/home.js +3 -76
  29. package/src/ui/session.js +34 -528
  30. package/src/ui/tree.js +2 -8
  31. package/src/ui/workspaces.js +2 -122
  32. package/src/ui-server.js +1 -38
  33. package/src/api/routes/github.js +0 -380
  34. package/src/branches.js +0 -194
  35. package/src/clash.js +0 -128
  36. package/src/commit-request.js +0 -59
  37. package/src/commit.js +0 -213
  38. package/src/git-hook.js +0 -48
  39. package/src/gitignore.js +0 -49
  40. package/src/gitstate.js +0 -1069
  41. package/src/relay-ci.js +0 -111
  42. package/src/relay-clash.js +0 -214
  43. package/src/relay-commit.js +0 -923
  44. package/src/relay-filekey.js +0 -46
  45. package/src/relay-git.js +0 -274
  46. package/src/relay-githttp.js +0 -166
  47. package/src/relay-github.js +0 -109
  48. package/src/relay-outbound.js +0 -1295
  49. package/src/relay-repo-moves.js +0 -43
  50. package/src/relay-sync.js +0 -795
  51. package/src/relay-upstream.js +0 -889
  52. package/src/sync-words.js +0 -42
  53. package/src/upstream.js +0 -192
package/src/clash.js DELETED
@@ -1,128 +0,0 @@
1
- // Commits from outside that a folder can't bring in (git couldn't read a file
2
- // they change: too large, or behind a filter such as Git LFS) are handed to ONE
3
- // member as a task on the board, instead of every folder behind on that branch
4
- // telling its AI. Files both sides changed never come here: Quilt combines them
5
- // itself (upstream.js combine). Each folder that reports the clash works out the same owner from
6
- // presence; only the owner's Quilt writes the task, under an id derived from
7
- // the branch and the upstream commit, so two that race write the same task.
8
- // Pure: Session (reviewClash) reads presence and the board and applies these.
9
- import crypto from 'node:crypto'
10
- import { MAX_FILES, MAX_TITLE } from './tasks.js'
11
-
12
- const HEX_ID = /^[0-9a-f]{16}$/
13
- const SHA = /^[0-9a-f]{7,64}$/
14
-
15
- /** The task id for a clash on `branch` with upstream commit `sha`: the same in every member's Quilt. */
16
- export function clashTaskId (branch, sha) {
17
- return crypto.createHash('sha256').update(`quilt-clash\n${branch}\n${sha}`).digest('hex').slice(0, 16)
18
- }
19
-
20
- /** A clash record from the room's "clashes" map (branch -> record), or null for anything else. */
21
- export function readClash (value, branch) {
22
- if (!value || typeof value !== 'object') return null
23
- if (typeof value.task !== 'string' || !HEX_ID.test(value.task)) return null
24
- if (typeof value.sha !== 'string' || !SHA.test(value.sha)) return null
25
- if (typeof value.upstream !== 'string' || !value.upstream || value.upstream.length > 200) return null
26
- const ts = Number.isFinite(value.ts) ? value.ts : 0
27
- return { branch, task: value.task, sha: value.sha, upstream: value.upstream, ts }
28
- }
29
-
30
- /** Whether a member's presence shows a live AI session working through its app (persona.js). */
31
- export function hasAi (st) {
32
- return !!st && Array.isArray(st.personas) && st.personas.some((p) => p && typeof p.name === 'string' && p.name)
33
- }
34
-
35
- /**
36
- * The members who may take this clash: their folder is on `branch` with `upstream` as its
37
- * upstream and reports clashing files (not a diverged branch: that history is the folder's own),
38
- * their Quilt hands clashes out (git.clash 1), and they may change the clashing files.
39
- * `states`: [[clientID, presence state]], this member's own included.
40
- * Each: { id, name, kind, ai, rank } (rank 0 an agent member, 1 a person with an AI session, 2 a person).
41
- */
42
- export function clashCandidates (states, { branch, upstream }) {
43
- const out = []
44
- for (const [id, st] of states) {
45
- if (!st || typeof st.name !== 'string' || !st.name || !st.git || typeof st.git !== 'object') continue
46
- const g = st.git
47
- const u = g.upstream
48
- if (g.clash !== 1 || g.branch !== branch || !u || u.name !== upstream) continue
49
- // A folder whose fetch fails can't pull, so its AI couldn't merge.
50
- if (!(u.conflicts > 0) || u.diverged || u.mayWrite !== true || u.fetchOk === false) continue
51
- const agent = st.kind === 'agent'
52
- const ai = !agent && hasAi(st)
53
- out.push({ id, name: st.name, kind: agent ? 'agent' : 'human', ai, rank: agent ? 0 : ai ? 1 : 2 })
54
- }
55
- return out
56
- }
57
-
58
- /** The candidates in the order they take the clash: an agent member first, then a person with an AI, then anyone; the lowest client id among equals. */
59
- export function clashOrder (candidates) {
60
- return [...candidates].sort((a, b) => a.rank - b.rank || a.id - b.id)
61
- }
62
-
63
- /** Who takes the clash (the first in clashOrder not in `except`), or null. */
64
- export function pickClashOwner (candidates, { except = [] } = {}) {
65
- return clashOrder(candidates.filter((c) => !except.includes(c.name)))[0] || null
66
- }
67
-
68
- /**
69
- * The presence state of whoever `assignee` is (a member, or one of a member's AI sessions),
70
- * counted only when that member's folder is on `branch`; null when they are not here.
71
- */
72
- export function holderOf (states, assignee, branch) {
73
- for (const [id, st] of states) {
74
- if (!st || !st.git || st.git.branch !== branch) continue
75
- if (st.name === assignee || (Array.isArray(st.personas) && st.personas.some((p) => p && p.name === assignee))) return { id, st, rank: st.kind === 'agent' ? 0 : hasAi(st) ? 1 : 2 }
76
- }
77
- return null
78
- }
79
-
80
- /** How a task's assignee is named in a notice and on the branch list: an agent by name, a person as "<name>'s AI". */
81
- export function ownerLabel ({ assignee, forAi }) {
82
- return forAi ? `${assignee}'s AI` : assignee
83
- }
84
-
85
- const n = (k, w) => `${k} ${w}${k === 1 ? '' : 's'}`
86
-
87
- /** The task's title: "Bring 4 commits from origin/main into the session: src/a.js, src/b.js clash". */
88
- export function clashTitle ({ upstream, behind, conflicts = [] }) {
89
- const head = `Bring ${n(behind, 'commit')} from ${upstream} into the session: `
90
- const paths = conflicts.map((c) => c.path)
91
- const tail = paths.length === 1 ? ' clashes' : ' clash'
92
- const text = (shown) => {
93
- const rest = paths.length - shown.length
94
- return head + shown.join(', ') + (rest ? ` and ${rest} more` : '') + tail
95
- }
96
- let shown = []
97
- for (const p of paths) {
98
- const next = [...shown, p]
99
- if (shown.length && text(next).length > MAX_TITLE) break
100
- shown = next
101
- }
102
- return text(shown).slice(0, MAX_TITLE)
103
- }
104
-
105
- /** The files the task lists (the board keeps at most MAX_FILES). */
106
- export function clashFiles (conflicts = []) {
107
- return [...new Set(conflicts.map((c) => c.path))].slice(0, MAX_FILES)
108
- }
109
-
110
- /** The task's description, as its first comment: each file and why. Nobody is asked to pull or merge. */
111
- export function clashBrief ({ branch, upstream, sha, behind, conflicts = [] }) {
112
- const lines = [`${n(behind, 'commit')} on ${upstream} (up to ${sha.slice(0, 7)}) change files Quilt can't read here, so it brought nothing in yet:`]
113
- for (const c of conflicts.slice(0, 30)) lines.push(`- ${c.path}: ${c.why}`)
114
- if (conflicts.length > 30) lines.push(`- and ${conflicts.length - 30} more`)
115
- lines.push('', `Files both sides changed are combined by Quilt on its own; nothing here needs merging by hand. Once a folder on \`${branch}\` that can read these files is at ${sha.slice(0, 7)} or later, every other folder follows it and Quilt closes this task by itself: no need to move it.`)
116
- return lines.join('\n')
117
- }
118
-
119
- /** The notice for a member whose folder is behind but who is not merging: leave it to the owner. */
120
- export function leaveItNotice ({ label, upstream, task, facts }) {
121
- return `${label} is bringing in the commits from ${upstream} (task ${task}); leave those files to them.${facts ? ` ${facts}` : ''}`
122
- }
123
-
124
- /** What a folder that reports the clash knows about it, without the advice to pull. */
125
- export function clashFacts ({ branch, upstream, behind, conflicts = [] }) {
126
- const list = conflicts.slice(0, 4).map((c) => `${c.path} (${c.why})`).join('; ') + (conflicts.length > 4 ? `; and ${conflicts.length - 4} more` : '')
127
- return `${branch} is ${n(behind, 'commit')} behind ${upstream}, and Quilt can't read ${list} to bring them in.`
128
- }
@@ -1,59 +0,0 @@
1
- // Commit requests (quilt_request_commit): an AI or a person asks for a commit, says what it
2
- // is for and which files changed, and the session owner (whose machine usually commits) is
3
- // told with a direct message. Shared by the folder's Session and the relay's hosted MCP so
4
- // both build the same request and the same message.
5
- import { isSafeRelPath } from './pathrules.js'
6
-
7
- export const MAX_COMMIT_MESSAGE = 500
8
- export const MAX_COMMIT_DESCRIPTION = 2000
9
- export const MAX_COMMIT_FILES = 100
10
-
11
- /** The changed files an agent listed: clean relative paths, no duplicates, at most MAX_COMMIT_FILES. */
12
- export function cleanCommitFiles (files) {
13
- if (files == null) return []
14
- if (!Array.isArray(files)) throw new Error('files must be a list of paths')
15
- const out = []
16
- for (const f of files) {
17
- const rel = String(f == null ? '' : f).trim().replace(/\\/g, '/').replace(/^\.\//, '').replace(/\/+$/, '')
18
- if (!rel) continue
19
- if (!isSafeRelPath(rel)) throw new Error(`not a path in the project: ${String(f).slice(0, 200)}`)
20
- if (!out.includes(rel)) out.push(rel)
21
- }
22
- if (out.length > MAX_COMMIT_FILES) throw new Error(`list at most ${MAX_COMMIT_FILES} files`)
23
- return out
24
- }
25
-
26
- /** The owner's name from a member list ({ name, role }), or ''. */
27
- export function ownerName (members) {
28
- const o = (members || []).find((m) => m && m.role === 'owner' && m.name)
29
- return o ? String(o.name) : ''
30
- }
31
-
32
- /** A new open request. Throws when there is nothing to say. */
33
- export function makeCommitRequest ({ id, by, message, description = '', files = [], branch = '', ts = Date.now() }) {
34
- const msg = String(message || '').trim().slice(0, MAX_COMMIT_MESSAGE)
35
- if (!msg) throw new Error('say what the commit is for')
36
- const desc = String(description || '').trim().slice(0, MAX_COMMIT_DESCRIPTION)
37
- const list = cleanCommitFiles(files)
38
- return { id, by, message: msg, ...(desc ? { description: desc } : {}), ...(list.length ? { files: list } : {}), ...(branch ? { branch } : {}), ts, state: 'open' }
39
- }
40
-
41
- /** What the owner is told: who wants a commit, what for, which files, and how to settle it. */
42
- export function commitRequestText (r, { defaultBranch = 'main' } = {}) {
43
- const branch = r.branch && r.branch !== defaultBranch ? ` on \`${r.branch}\`` : ''
44
- const files = r.files && r.files.length
45
- ? ` Files (${r.files.length}): ${r.files.slice(0, 20).join(', ')}${r.files.length > 20 ? `, and ${r.files.length - 20} more` : ''}.`
46
- : ''
47
- const desc = r.description ? ` ${r.description.replace(/\s+/g, ' ')}` : ''
48
- return `📌 Commit requested${branch} (${r.id}): ${r.message.replace(/[.!?]+$/, '')}.${desc}${files} ` +
49
- 'Commit with git when it is a good moment (quilt_commit_status), then mark it done with quilt_commit_request_done.'
50
- }
51
-
52
- /** The chat message that tells the owner, or null when there is no owner to tell or the owner asked. */
53
- export function commitRequestMessage (r, { owner, id, defaultBranch, of = null }) {
54
- if (!owner || owner === r.by) return null
55
- return { id, by: r.by, to: owner, text: commitRequestText(r, { defaultBranch }), ts: r.ts, kind: 'commit', commit: r.id, ...(of ? { of } : {}) }
56
- }
57
-
58
- /** A message that tells the owner about a commit request: settled by committing, not by a reply. */
59
- export const commitRequestNote = (m) => !!m && m.kind === 'commit'
package/src/commit.js DELETED
@@ -1,213 +0,0 @@
1
- // Commit requests, committed in a person's folder with git: exactly the files a request names
2
- // (those that differ from HEAD), with its message, then pushed to the branch's upstream. This is
3
- // the way in when the relay can't commit for the asker (relay-commit.js: no GitHub token, a
4
- // repository not on GitHub, or the owner keeps agents from committing): a person commits it from
5
- // the app's commit panel (session.js: commitRequest).
6
- //
7
- // Committing touches no file: `git commit --only -- <paths>` moves the branch and the index
8
- // entries of those paths, and leaves everything else (other people's uncommitted work, files
9
- // someone staged by hand) as it was. Quilt never merges history: a push git refuses (the
10
- // remote moved on) is reported, not forced.
11
- //
12
- // The pure parts (who changed which uncommitted file, what a person should know before
13
- // committing a request, the commit message) are here too, so the app, the daemon and tests agree.
14
- import { execFile } from 'node:child_process'
15
- import { isSafeRelPath, globMatcher } from './pathrules.js'
16
-
17
- export const COMMIT_TIMEOUT_MS = 60 * 1000 // a pre-commit hook may run tests
18
- export const PUSH_TIMEOUT_MS = 90 * 1000
19
- export const MAX_REQUEST_FILES = 500
20
- export const MAX_MESSAGE = 500
21
- // How long a request is left alone after it is made: the asker's AI may still be saving its files.
22
- export const REQUEST_SETTLE_MS = 60 * 1000
23
-
24
- const NO_PROMPT = { GIT_TERMINAL_PROMPT: '0', GCM_INTERACTIVE: 'never', GIT_OPTIONAL_LOCKS: '0' }
25
-
26
- /** Runs git: { ok, out, err }. Never rejects. */
27
- function git (root, args, { timeout = 15000, env = {} } = {}) {
28
- return new Promise((resolve) => {
29
- let child
30
- try {
31
- child = execFile(process.env.QUILT_GIT || 'git', args, { cwd: root, timeout, maxBuffer: 64 * 1024 * 1024, env: { ...process.env, ...NO_PROMPT, ...env } },
32
- (err, stdout, stderr) => resolve({ ok: !err, out: String(stdout || ''), err: String(stderr || (err && err.message) || '') }))
33
- } catch (err) { return resolve({ ok: false, out: '', err: err.message }) }
34
- child.stdin.end()
35
- })
36
- }
37
-
38
- /** The paths a request may name: relative, inside the folder, no duplicates. */
39
- export function cleanFiles (files) {
40
- const out = []
41
- for (const f of Array.isArray(files) ? files : []) {
42
- const p = String(f || '').trim().replace(/\\/g, '/').replace(/^\.\//, '')
43
- if (!isSafeRelPath(p) || out.includes(p)) continue
44
- out.push(p)
45
- if (out.length >= MAX_REQUEST_FILES) break
46
- }
47
- return out
48
- }
49
-
50
- /**
51
- * Which of `files` differ from HEAD (changed, new or deleted, not ignored): Map path -> 'M' | 'A' | 'D'.
52
- * Null when git can't say.
53
- */
54
- export async function uncommitted (root, files = null) {
55
- const args = ['-c', 'core.quotePath=false', 'status', '--porcelain=v1', '-z', '--no-renames', '--untracked-files=all']
56
- if (files) {
57
- if (!files.length) return new Map()
58
- args.push('--', ...files.map((f) => `:(literal)${f}`))
59
- }
60
- const r = await git(root, args)
61
- if (!r.ok) return null
62
- const out = new Map()
63
- for (const rec of r.out.split('\0')) {
64
- if (rec.length < 4) continue
65
- const xy = rec.slice(0, 2)
66
- const rel = rec.slice(3)
67
- if (xy === '!!') continue
68
- out.set(rel, xy === '??' || xy.includes('A') ? 'A' : xy.includes('D') ? 'D' : 'M')
69
- }
70
- return out
71
- }
72
-
73
- /** When each path was last committed (ms), from the newest `depth` commits: Map path -> ts. */
74
- export async function lastCommitted (root, { depth = 2000 } = {}) {
75
- const r = await git(root, ['-c', 'core.quotePath=false', 'log', `-n${depth}`, '--format=%x00%ct', '--name-only', 'HEAD'])
76
- const out = new Map()
77
- if (!r.ok) return out
78
- let ts = 0
79
- for (const line of r.out.split('\n')) {
80
- if (line.startsWith('\0')) { ts = Number(line.slice(1)) * 1000 || 0; continue }
81
- if (line && !out.has(line)) out.set(line, ts)
82
- }
83
- return out
84
- }
85
-
86
- /**
87
- * Commits the request's files that differ from HEAD, and pushes. Resolves
88
- * { hash, files, pushed, pushError, nothing } or throws with a plain reason (git refused, a hook failed).
89
- * `push` false only commits. `upstream` is { remote, name } (gitstate.upstreamOf); without one nothing is pushed.
90
- */
91
- export async function commitFiles (root, { files, message, push = true, upstream = null, branch = '' } = {}) {
92
- const wanted = cleanFiles(files)
93
- const changed = await uncommitted(root, wanted)
94
- if (!changed) throw new Error('git could not read this folder')
95
- const paths = wanted.filter((f) => changed.has(f))
96
- if (!paths.length) return { hash: null, files: [], pushed: false, pushError: null, nothing: true }
97
- const spec = paths.map((f) => `:(literal)${f}`)
98
- // New files must be known to git before `commit --only` takes them; deletions are taken as they are.
99
- const added = paths.filter((f) => changed.get(f) === 'A')
100
- if (added.length) {
101
- const a = await git(root, ['add', '--', ...added.map((f) => `:(literal)${f}`)])
102
- if (!a.ok) throw new Error(`git add failed: ${firstLine(a.err)}`)
103
- }
104
- const c = await git(root, ['commit', '--only', '--no-edit', '-m', message, '--', ...spec], { timeout: COMMIT_TIMEOUT_MS })
105
- if (!c.ok) {
106
- // Leave nothing half-done: what was added for this commit goes back to untracked.
107
- if (added.length) await git(root, ['reset', '-q', '--', ...added.map((f) => `:(literal)${f}`)])
108
- throw new Error(`git commit failed: ${firstLine(c.err || c.out)}`)
109
- }
110
- const hash = (await git(root, ['rev-parse', 'HEAD'])).out.trim()
111
- if (!push || !upstream || !upstream.remote || upstream.remote === '.') return { hash, files: paths, pushed: false, pushError: upstream ? null : 'this branch has no upstream to push to', nothing: false }
112
- const target = String(upstream.name || '').startsWith(`${upstream.remote}/`) ? upstream.name.slice(upstream.remote.length + 1) : branch
113
- const p = await git(root, ['-c', 'core.askPass=', 'push', '--quiet', upstream.remote, `HEAD:refs/heads/${target}`], {
114
- timeout: PUSH_TIMEOUT_MS,
115
- env: { GIT_ASKPASS: '', SSH_ASKPASS: '', GIT_SSH_COMMAND: process.env.GIT_SSH_COMMAND || 'ssh -o BatchMode=yes' }
116
- })
117
- return { hash, files: paths, pushed: p.ok, pushError: p.ok ? null : pushProblem(p.err, upstream.remote, target), nothing: false }
118
- }
119
-
120
- const firstLine = (s) => String(s || '').split('\n').map((l) => l.replace(/^(fatal|error):\s*/i, '').trim()).find(Boolean)?.slice(0, 200) || 'unknown error'
121
-
122
- /** Why a push failed, in plain words (never a URL: it may carry credentials). */
123
- export function pushProblem (stderr, remote = 'origin', branch = '') {
124
- const e = String(stderr || '').toLowerCase()
125
- if (/non-fast-forward|fetch first|rejected/.test(e)) return `${remote}/${branch} has commits this folder doesn't have yet: the commit is made here and will be pushed after Quilt catches the branch up`
126
- if (/could not read (username|password)|terminal prompts disabled|authentication failed|permission denied|repository not found|could not read from remote|access denied|returned error: 40[13]/.test(e)) return `git can't sign in to ${remote} from this folder to push`
127
- if (/could not resolve host|failed to connect|unable to access|network is unreachable|timed out/.test(e)) return `can't reach ${remote} (no network?)`
128
- return `git push failed: ${firstLine(stderr).replace(/[a-z]+:\/\/[^\s'"]*@/gi, '')}`
129
- }
130
-
131
- /** The commit message: what the request says, and who asked (and for which task). */
132
- export function commitMessage (r) {
133
- const first = String(r.message || '').trim().split('\n')[0].slice(0, 120) || 'Changes from the Quilt session'
134
- const rest = String(r.message || '').trim().split('\n').slice(1).join('\n').trim()
135
- const by = r.by ? `Asked for by ${r.by} in Quilt${r.task ? ` (task ${r.task.id}: ${r.task.title})` : ''}.` : ''
136
- return [first, rest, by].filter(Boolean).join('\n\n')
137
- }
138
-
139
- /**
140
- * Who made each uncommitted change, from the session's history: for each path in `dirty`, the
141
- * people whose own edits (not pulls from git) to it came after it was last committed.
142
- * Returns Map path -> [names], newest editor last. `history`: entries, oldest first.
143
- */
144
- export function editorsSince (dirty, history, committedAt = new Map()) {
145
- const out = new Map()
146
- for (const rel of dirty) out.set(rel, [])
147
- for (const e of history || []) {
148
- if (!e || !out.has(e.path) || e.pulled || !e.by) continue
149
- if ((e.ts || 0) <= (committedAt.get(e.path) || 0)) continue
150
- const list = out.get(e.path)
151
- const i = list.indexOf(e.by)
152
- if (i >= 0) list.splice(i, 1)
153
- list.push(e.by)
154
- }
155
- return out
156
- }
157
-
158
- /**
159
- * Uncommitted work grouped by who made it: [{ by, files }], most files first. A file several
160
- * people changed is listed under each. Files no history entry explains go under ''.
161
- */
162
- export function uncommittedByPerson (editors) {
163
- const groups = new Map()
164
- for (const [rel, names] of editors) {
165
- for (const n of names.length ? names : ['']) {
166
- if (!groups.has(n)) groups.set(n, [])
167
- groups.get(n).push(rel)
168
- }
169
- }
170
- return [...groups].map(([by, files]) => ({ by, files: files.sort() })).sort((a, b) => b.files.length - a.files.length || a.by.localeCompare(b.by))
171
- }
172
-
173
- /**
174
- * What the person committing a request should know first, or '': a file with conflict markers,
175
- * one that also holds others' uncommitted changes (they would go in too), one someone else holds,
176
- * or an asker still at work.
177
- */
178
- export function requestWarning (r, { editors, claims = [], markers = new Set(), taskEditors = new Set(), busy = [], now = Date.now() } = {}) {
179
- if (now - (r.ts || 0) < REQUEST_SETTLE_MS) return 'just asked'
180
- if (busy.includes(r.by)) return `${r.by} is still working`
181
- for (const [rel, names] of editors) {
182
- if (markers.has(rel)) return `${rel} has conflict markers`
183
- const others = names.filter((n) => n !== r.by && !taskEditors.has(n))
184
- if (others.length) return `${rel} also has uncommitted changes by ${others.join(', ')}`
185
- const held = claims.find((c) => c && c.by && c.by !== r.by && globMatcher(String(c.pattern || ''))(rel))
186
- if (held) return `${held.by} holds ${rel}`
187
- }
188
- return ''
189
- }
190
-
191
- // ------------------------------------------------------------- words --
192
- // The same for the local and the hosted tools, and the HTTP routes' docs.
193
-
194
- export const REQUEST_COMMIT_DESCRIPTION = 'Ask a person to commit your finished work, when you can\'t get it into git yourself (the owner keeps agents from committing, nobody has connected GitHub, or the repository is not on GitHub). ' +
195
- 'A person with git on this branch commits exactly these files (those that differ from git) with your message, from their app, and you are told the commit. ' +
196
- 'Give the files you changed; without them, the task\'s changes (task) or every change of yours on record are asked for.'
197
-
198
- /** Open requests (with ids, files and what holds them up) and recent commits, as lines for an agent. */
199
- export function describeCommitRequests (c, { canCommit = false } = {}) {
200
- const lines = []
201
- const open = c.open || []
202
- if (open.length) {
203
- lines.push('Open commit requests:')
204
- for (const r of open) {
205
- const files = Array.isArray(r.files) && r.files.length ? ` (${r.files.length} file${r.files.length === 1 ? '' : 's'}: ${r.files.slice(0, 8).join(', ')}${r.files.length > 8 ? ', …' : ''})` : ''
206
- lines.push(`- [${r.id}] ${r.by}${r.branch ? ` on \`${r.branch}\`` : ''}: ${r.message}${r.task ? ` (task ${r.task.id})` : ''}${files}${r.error ? `. Last try: ${r.error}` : ''}`)
207
- }
208
- lines.push(canCommit ? 'This folder has git: its app commits a request with its Commit button.' : 'A person with git on that branch commits them from their app; the asker is told the commit.')
209
- } else lines.push('No open commit requests.')
210
- const recent = (c.recent || []).filter((r) => r.hash)
211
- if (recent.length) lines.push('Committed lately:', ...recent.map((r) => `- ${r.hash.slice(0, 7)} ${r.message} (${r.by}${r.pushed === false ? `; not pushed${r.pushError ? `: ${r.pushError}` : ''}` : ''})`))
212
- return lines.join('\n')
213
- }
package/src/git-hook.js DELETED
@@ -1,48 +0,0 @@
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/gitignore.js DELETED
@@ -1,49 +0,0 @@
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
- }