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.
- package/RELEASES.md +10 -0
- package/bin/quilt.js +7 -143
- package/package.json +1 -1
- package/src/account.js +0 -29
- package/src/api/memory-store.js +0 -29
- package/src/api/routes/teams.js +0 -23
- package/src/api/server.js +5 -19
- package/src/api/supabase-store.js +0 -52
- package/src/branch-view.js +6 -131
- package/src/control.js +2 -33
- package/src/duties.js +1 -1
- package/src/github-commit.js +3 -315
- package/src/mcp.js +8 -157
- package/src/relay-ask.js +3 -66
- package/src/relay-cluster.js +3 -5
- package/src/relay-mcp.js +6 -418
- package/src/relay-prune.js +6 -145
- package/src/relay-turn-sync.js +1 -1
- package/src/server.js +33 -1331
- package/src/session-follow.js +18 -5
- package/src/session.js +58 -3526
- package/src/status.js +2 -58
- package/src/turn-sync.js +15 -2
- package/src/ui/changes.js +5 -14
- package/src/ui/chat.js +0 -1
- package/src/ui/common.js +0 -2
- package/src/ui/git-rule.js +2 -30
- package/src/ui/home.js +3 -76
- package/src/ui/session.js +34 -528
- package/src/ui/tree.js +2 -8
- package/src/ui/workspaces.js +2 -122
- package/src/ui-server.js +1 -38
- package/src/api/routes/github.js +0 -380
- package/src/branches.js +0 -194
- package/src/clash.js +0 -128
- package/src/commit-request.js +0 -59
- package/src/commit.js +0 -213
- package/src/git-hook.js +0 -48
- package/src/gitignore.js +0 -49
- package/src/gitstate.js +0 -1069
- package/src/relay-ci.js +0 -111
- package/src/relay-clash.js +0 -214
- package/src/relay-commit.js +0 -923
- package/src/relay-filekey.js +0 -46
- package/src/relay-git.js +0 -274
- package/src/relay-githttp.js +0 -166
- package/src/relay-github.js +0 -109
- package/src/relay-outbound.js +0 -1295
- package/src/relay-repo-moves.js +0 -43
- package/src/relay-sync.js +0 -795
- package/src/relay-upstream.js +0 -889
- package/src/sync-words.js +0 -42
- 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
|
-
}
|
package/src/commit-request.js
DELETED
|
@@ -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
|
-
}
|