heyquilt 0.3.138 → 0.3.140

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 (52) hide show
  1. package/RELEASES.md +6 -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.js +52 -3526
  21. package/src/status.js +2 -58
  22. package/src/turn-sync.js +15 -2
  23. package/src/ui/changes.js +5 -14
  24. package/src/ui/chat.js +0 -1
  25. package/src/ui/common.js +0 -2
  26. package/src/ui/git-rule.js +2 -30
  27. package/src/ui/home.js +3 -76
  28. package/src/ui/session.js +34 -528
  29. package/src/ui/tree.js +2 -8
  30. package/src/ui/workspaces.js +2 -122
  31. package/src/ui-server.js +1 -38
  32. package/src/api/routes/github.js +0 -380
  33. package/src/branches.js +0 -194
  34. package/src/clash.js +0 -128
  35. package/src/commit-request.js +0 -59
  36. package/src/commit.js +0 -213
  37. package/src/git-hook.js +0 -48
  38. package/src/gitignore.js +0 -49
  39. package/src/gitstate.js +0 -1069
  40. package/src/relay-ci.js +0 -111
  41. package/src/relay-clash.js +0 -214
  42. package/src/relay-commit.js +0 -923
  43. package/src/relay-filekey.js +0 -46
  44. package/src/relay-git.js +0 -274
  45. package/src/relay-githttp.js +0 -166
  46. package/src/relay-github.js +0 -109
  47. package/src/relay-outbound.js +0 -1295
  48. package/src/relay-repo-moves.js +0 -43
  49. package/src/relay-sync.js +0 -795
  50. package/src/relay-upstream.js +0 -889
  51. package/src/sync-words.js +0 -42
  52. package/src/upstream.js +0 -192
package/src/gitstate.js DELETED
@@ -1,1069 +0,0 @@
1
- // What git is doing to a synced folder. Quilt reads git: it asks git whether a burst of file changes was an edit, a discard (stash,
2
- // reset, restore), new commits (pull, merge, rebase) or a branch switch, and
3
- // what a file looked like at a commit, so the shared work can be kept apart
4
- // from what git did. It writes to git in two ways only, both at the end of
5
- // this file: a background fetch (remote-tracking refs, never files or
6
- // branches), and moving the folder's own branch forward to commits it is
7
- // behind (fast-forward only, never merging history; see bringIn in session.js). Every git call is asynchronous (the app's window and its
8
- // relay connection never wait on git); the file reads below are synchronous.
9
- import fs from 'node:fs'
10
- import os from 'node:os'
11
- import path from 'node:path'
12
- import { execFile } from 'node:child_process'
13
- import { watch } from 'chokidar'
14
- import { looksBinary, sha1, MAX_STORED_BINARY_BYTES, closeWatcher } from './fsutil.js'
15
-
16
- export const GIT_TIMEOUT_MS = 5000
17
- export const SETTLE_MS = 2000
18
- export const BURST_PATHS = 20
19
- // An index.lock this old, with no other operation under way, may be one a crashed git left behind.
20
- export const STALE_LOCK_MS = 60 * 1000
21
-
22
- const MARKERS = [
23
- ['index.lock', 'index-lock'], ['MERGE_HEAD', 'merge'], ['rebase-merge', 'rebase'], ['rebase-apply', 'rebase'],
24
- ['CHERRY_PICK_HEAD', 'cherry-pick'], ['REVERT_HEAD', 'revert'], ['BISECT_LOG', 'bisect']
25
- ]
26
-
27
- // The index locks this process holds (lockIndex): Quilt's own, not another git at work.
28
- const ownLocks = new Set()
29
-
30
- // Output read in one go: a listing of a big repository, or a batch of files (see filesAt).
31
- const MAX_OUTPUT = 256 * 1024 * 1024
32
- // More paths than this are not passed on the command line: the whole tree is asked, and filtered.
33
- const MAX_PATHSPECS = 200
34
-
35
- /** Runs git: its output (a string, or a Buffer with `buffer`), or null when it fails. */
36
- async function run (root, args, opts) {
37
- return (await call(root, args, opts)).out
38
- }
39
-
40
- const gitBinary = () => process.env.QUILT_GIT || 'git'
41
-
42
- // Whether the last git call in a folder was stopped at GIT_TIMEOUT_MS (per folder: several
43
- // sessions can be asking git at once).
44
- const timedOut = new Map()
45
- let lastTimedOut = false
46
-
47
- /**
48
- * Runs git: { out } or, when it fails, { out: null, missing, timedOut }
49
- * (missing: the git binary could not be started at all; timedOut: it ran
50
- * past GIT_TIMEOUT_MS and was stopped). Never rejects.
51
- */
52
- function call (root, args, { buffer = false, input, timeout = GIT_TIMEOUT_MS, env = {} } = {}) {
53
- // QUILT_GIT lets tests point at a git binary that doesn't exist, to exercise
54
- // the "git is unreachable" path without touching the real PATH.
55
- const bin = gitBinary()
56
- return new Promise((resolve) => {
57
- const done = (out, missing, late, stderr = '') => {
58
- timedOut.set(root, late); lastTimedOut = late
59
- resolve({ out, missing, timedOut: late, stderr })
60
- }
61
- let child
62
- try {
63
- child = execFile(bin, args, {
64
- cwd: root,
65
- encoding: buffer ? 'buffer' : 'utf8',
66
- timeout,
67
- maxBuffer: MAX_OUTPUT,
68
- env: { ...process.env, GIT_OPTIONAL_LOCKS: '0', ...env }
69
- }, (err, stdout, stderr) => {
70
- if (!err) { runs.add(bin); return done(stdout, false, false) }
71
- const missing = err.code === 'ENOENT' || err.code === 'EACCES'
72
- if (!missing) runs.add(bin) // it started, then failed or ran too long: git is there
73
- done(null, missing, !missing && (err.killed === true || err.signal === 'SIGTERM'), String(stderr || ''))
74
- })
75
- } catch (err) { // a cwd that is gone, say
76
- return done(null, err.code === 'ENOENT' || err.code === 'EACCES', false)
77
- }
78
- child.stdin.on('error', () => {}) // git may exit before reading all of it
79
- child.stdin.end(input)
80
- })
81
- }
82
-
83
- const runs = new Set() // git binaries seen to run
84
-
85
- /**
86
- * Whether the last git call (in `root`, or anywhere) ran past GIT_TIMEOUT_MS:
87
- * asking again would stall the folder as long again.
88
- */
89
- export function lastCallTimedOut (root) { return root === undefined ? lastTimedOut : !!timedOut.get(root) }
90
-
91
- /**
92
- * `ask()` (a read of git's that resolves to null when git fails), asked once
93
- * more when it failed, unless that call timed out or git can't be run at all.
94
- */
95
- export async function askTwice (root, ask) {
96
- const r = await ask()
97
- return r !== null || lastCallTimedOut(root) || !(await gitRuns(root)) ? r : ask()
98
- }
99
-
100
- /**
101
- * Whether git itself can be run here (`git --version`), as against one call
102
- * of it failing (a timeout, a filter, a big read). Remembered once it has run.
103
- */
104
- export async function gitRuns (root) {
105
- const bin = gitBinary()
106
- if (runs.has(bin)) return true
107
- if (await run(root, ['--version']) === null) return false
108
- runs.add(bin)
109
- return true
110
- }
111
-
112
- /** The branch .git/HEAD names (even one with no commits yet), or null (detached, or not a repo). No git call. */
113
- export function headRef (root) {
114
- const dir = gitDir(root)
115
- if (!dir) return null
116
- try {
117
- const m = /^ref:\s*refs\/heads\/(.+)$/.exec(fs.readFileSync(path.join(dir, 'HEAD'), 'utf8').trim())
118
- return m ? m[1] : null
119
- } catch { return null }
120
- }
121
-
122
- /** A file's bytes as Quilt keys them (lastKnown, sharedKey): its text, or "bin:<sha1>". */
123
- function keyOf (buf) {
124
- return looksBinary(buf) ? `bin:${sha1(buf)}` : buf.toString('utf8')
125
- }
126
-
127
- /** The folder's .git directory (a worktree's .git file points at it), or null. */
128
- export function gitDir (root) {
129
- const dot = path.join(root, '.git')
130
- let st
131
- try { st = fs.statSync(dot) } catch { return null }
132
- if (st.isDirectory()) return dot
133
- try {
134
- const m = /^gitdir:\s*(.+)$/m.exec(fs.readFileSync(dot, 'utf8'))
135
- if (!m) return null
136
- const dir = path.resolve(root, m[1].trim())
137
- return fs.existsSync(dir) ? dir : null
138
- } catch { return null }
139
- }
140
-
141
- /**
142
- * Where HEAD points: a branch, or a detached commit. Null when the folder is not a
143
- * repo, when git is unreachable (sha lookup fails), or on an unborn branch (a fresh
144
- * `git init` with no commits yet) — in every case there's nothing to compare against.
145
- */
146
- export async function headKey (root) {
147
- const dir = gitDir(root)
148
- if (!dir) return null
149
- let head
150
- try { head = fs.readFileSync(path.join(dir, 'HEAD'), 'utf8').trim() } catch { return null }
151
- const sha = ((await run(root, ['rev-parse', '--verify', '-q', 'HEAD'])) || '').trim() || null
152
- if (!sha) return null
153
- const ref = /^ref:\s*refs\/heads\/(.+)$/.exec(head)
154
- if (ref) return { key: ref[1], branch: ref[1], sha }
155
- return { key: `@${sha.slice(0, 12)}`, branch: null, sha }
156
- }
157
-
158
- /**
159
- * The git operation in progress in this folder, or null. `dir` lets a caller
160
- * that already has it skip re-resolving it. `leftover`: an index.lock known to
161
- * be left behind (its lockStamp, from leftoverLock), not counted; nor is one Quilt holds (lockIndex).
162
- */
163
- export function busy (root, dir = gitDir(root), leftover = null) {
164
- if (!dir) return null
165
- for (const [file, kind] of MARKERS) {
166
- if (!fs.existsSync(path.join(dir, file))) continue
167
- if (kind === 'index-lock' && ((leftover && lockStamp(root, dir) === leftover) || ownLocks.has(path.join(dir, file)))) continue
168
- return kind
169
- }
170
- return null
171
- }
172
-
173
- /** Which index.lock is there (its mtime and inode), or null. A new lock is another stamp. */
174
- export function lockStamp (root, dir = gitDir(root)) {
175
- if (!dir) return null
176
- try { const st = fs.statSync(path.join(dir, 'index.lock')); return `${st.mtimeMs}:${st.ino}` } catch { return null }
177
- }
178
-
179
- /**
180
- * The index.lock's stamp when it looks left behind by a git that crashed: the
181
- * only marker of an operation, older than STALE_LOCK_MS, and `git status`
182
- * runs. Null otherwise. The file is never touched: deleting it is yours to do.
183
- */
184
- export async function leftoverLock (root, now = Date.now()) {
185
- const dir = gitDir(root)
186
- if (busy(root, dir) !== 'index-lock') return null
187
- const stamp = lockStamp(root, dir)
188
- if (!stamp || busy(root, dir, stamp)) return null // another operation under way too
189
- if (now - Number(stamp.split(':')[0]) < STALE_LOCK_MS) return null
190
- if (await run(root, ['status', '--porcelain', '--untracked-files=no']) === null) return null
191
- return lockStamp(root, dir) === stamp ? stamp : null
192
- }
193
-
194
- /** A cheap signal that git wrote the index (stash, reset, checkout, add...). Editors never do. */
195
- export function indexStamp (root) {
196
- const dir = gitDir(root)
197
- if (!dir) return null
198
- try { const st = fs.statSync(path.join(dir, 'index')); return `${st.mtimeMs}:${st.size}` } catch { return null }
199
- }
200
-
201
- /**
202
- * What a burst of changes to `changed` was, given the head seen `before` it.
203
- * busy: a merge/rebase/... is mid-way (`leftover`: an index.lock to pay no
204
- * attention to, see leftoverLock). switch: HEAD names another branch (or
205
- * commit). advance: HEAD moved on the same branch (pull, merge, rebase done,
206
- * commit). discard: HEAD unchanged and every changed path is clean now (stash,
207
- * reset, restore). Otherwise edit, with `putBack`: the changed paths git may
208
- * have put back all the same (clean, and tracked or gone), so a discard that
209
- * shares its burst with an unrelated save (an untracked file, an autosave) can
210
- * still be told apart from an edit.
211
- * An untracked file that is gone counts as put back only when git wrote the
212
- * index in the same burst (`indexWrote`: `stash -u` puts it away and resets
213
- * the index); otherwise it was deleted (`git clean`, `rm`), and that is an edit.
214
- */
215
- export async function classify (root, { changed = [], before = null, leftover = null, indexWrote = false } = {}) {
216
- const head = await headKey(root)
217
- if (!head) return { kind: 'edit', head: null, prevHead: before, putBack: [] }
218
- if (busy(root, undefined, leftover)) return { kind: 'busy', head, prevHead: before }
219
- if (before && head.key !== before.key) return { kind: 'switch', head, prevHead: before }
220
- if (before && head.sha !== before.sha) return { kind: 'advance', head, prevHead: before }
221
- if (!changed.length) return { kind: 'edit', head, prevHead: before, putBack: [] }
222
- const tree = await treeState(root, changed)
223
- // git could not say (a timeout in a big repo): held as busy, and asked again when it settles.
224
- if (!tree) return { kind: 'busy', head, prevHead: before }
225
- // git left a conflict for the person (a `stash pop` that clashed leaves no MERGE_HEAD): mid-operation too.
226
- const conflict = changed.filter((rel) => tree.unmerged.has(rel))
227
- if (conflict.length) return { kind: 'busy', head, prevHead: before, conflict }
228
- // Clean paths print nothing, and so does a path git has never heard of (outside the repo, or nonexistent).
229
- const deleted = (rel) => !indexWrote && !tree.tracked.has(rel) && !exists(root, rel)
230
- const clean = changed.filter((rel) => !tree.dirty.has(rel) && !deleted(rel))
231
- if (clean.length === changed.length) return { kind: 'discard', head, prevHead: before }
232
- // As the settle tells them (planSettle): an untracked file git calls clean is still an edit.
233
- const putBack = clean.filter((rel) => tree.tracked.has(rel) || !exists(root, rel))
234
- return { kind: 'edit', head, prevHead: before, putBack }
235
- }
236
-
237
- function exists (root, rel) {
238
- try { fs.lstatSync(path.join(root, ...rel.split('/'))); return true } catch { return false }
239
- }
240
-
241
- /**
242
- * The file at a commit as Quilt keys it (its text, or "bin:<sha1>" for a
243
- * binary), as checkout would write it (--filters), or null when it did not
244
- * exist there (or git failed).
245
- */
246
- export async function fileAt (root, sha, rel) {
247
- const out = await filesAt(root, sha, [rel])
248
- return out ? out.get(rel) ?? null : null
249
- }
250
-
251
- /**
252
- * Several files at one commit, in a few git calls whatever their number:
253
- * rel -> key (as fileAt), null (not there), or undefined (not read: bigger
254
- * than Quilt shares, behind a filter such as Git LFS, or a git call failed).
255
- * `out.failed` counts the files a failed git call left unread. Null only
256
- * when git itself can't be run.
257
- */
258
- export async function filesAt (root, sha, rels) {
259
- const out = new Map()
260
- out.failed = 0
261
- if (!rels.length) return out
262
- const unread = (list) => { for (const rel of list) out.set(rel, undefined); out.failed += list.length }
263
- // Sizes first, so a huge file can't overflow the read of the contents.
264
- const check = await call(root, ['cat-file', '--batch-check=%(objectname) %(objecttype) %(objectsize)'], { input: rels.map((r) => `${sha}:${r}\n`).join('') })
265
- if (check.missing) return null
266
- const lines = check.out === null ? [] : check.out.split('\n').slice(0, rels.length)
267
- if (lines.length !== rels.length) { unread(rels); return out }
268
- // A path with a filter (Git LFS, say) is never read: its smudged size is unknown (the blob is
269
- // a pointer), and running the filter can be slow or fail. The attributes are asked in one call.
270
- const filtered = await filterOf(root, rels)
271
- if (filtered === null) { unread(rels); return out }
272
- const fetch = [] // [rel, blob id, size]
273
- rels.forEach((rel, i) => {
274
- const [id, type, size] = lines[i].split(' ')
275
- if (type !== 'blob' || !/^\d+$/.test(size)) out.set(rel, null) // "<name> missing", or a folder there
276
- else if (Number(size) > MAX_STORED_BINARY_BYTES || filtered.has(rel)) out.set(rel, undefined)
277
- else fetch.push([rel, id, Number(size)])
278
- })
279
- // With --filters each line is "<blob> <path>": the path picks the line-ending conversion, as
280
- // checkout would. That can change a size, so each file's own size is read from the batch's headers.
281
- // Older git (2.34, say, as many Windows machines have) gives the blob's own size there instead, so where
282
- // the conversion changes the length the size is checked: the content must end on the newline before the
283
- // next header (or the end). Where it doesn't, the rest are read one at a time, which is exact.
284
- const one = async (rel, id) => {
285
- const r = await call(root, ['cat-file', '--filters', `--path=${rel}`, id], { buffer: true })
286
- if (r.missing) return false
287
- if (r.out === null) { unread([rel]); return true }
288
- const key = keyOf(r.out)
289
- out.set(rel, key.startsWith(LFS_POINTER) ? undefined : key)
290
- return true
291
- }
292
- const HEADER = /^[0-9a-f]{40,64} (blob \d+|missing)$/
293
- const read = async (chunk) => {
294
- const buf = await call(root, ['cat-file', '--batch', '--filters'], { buffer: true, input: chunk.map(([rel, id]) => `${id} ${rel}\n`).join('') })
295
- if (buf.missing) return false
296
- if (buf.out === null) { unread(chunk.map(([rel]) => rel)); return true }
297
- let at = 0
298
- for (let i = 0; i < chunk.length; i++) {
299
- const rel = chunk[i][0]
300
- const nl = buf.out.indexOf(10, at)
301
- const header = nl < 0 ? '' : buf.out.subarray(at, nl).toString('utf8')
302
- if (/ missing$/.test(header)) { out.set(rel, null); at = nl + 1; continue }
303
- const size = Number(header.split(' ')[2])
304
- if (nl < 0 || !Number.isInteger(size)) { unread(chunk.slice(i).map(([r]) => r)); return true }
305
- const end = nl + 1 + size
306
- const last = i === chunk.length - 1
307
- const after = last ? '' : buf.out.subarray(end + 1, buf.out.indexOf(10, end + 1)).toString('utf8')
308
- if (buf.out[end] !== 10 || (last ? end + 1 !== buf.out.length : !HEADER.test(after))) {
309
- for (const [r, id] of chunk.slice(i)) if (!(await one(r, id))) return false
310
- return true
311
- }
312
- const key = keyOf(buf.out.subarray(nl + 1, end))
313
- out.set(rel, key.startsWith(LFS_POINTER) ? undefined : key) // an LFS pointer is not the file
314
- at = end + 1
315
- }
316
- return true
317
- }
318
- let chunk = []; let bytes = 0
319
- for (const f of fetch) {
320
- if (chunk.length && bytes + f[2] > MAX_OUTPUT / 4) {
321
- if (!(await read(chunk))) return null
322
- chunk = []; bytes = 0
323
- }
324
- chunk.push(f); bytes += f[2]
325
- }
326
- if (chunk.length && !(await read(chunk))) return null
327
- return out
328
- }
329
-
330
- const LFS_POINTER = 'version https://git-lfs'
331
-
332
- /** The paths with a `filter` attribute set (in .gitattributes), or null when git failed. */
333
- async function filterOf (root, rels) {
334
- const out = await run(root, ['check-attr', '-z', '--stdin', 'filter'], { input: rels.join('\0') + '\0' })
335
- if (out === null) return null
336
- const f = out.split('\0')
337
- const filtered = new Set()
338
- for (let i = 0; i + 2 < f.length; i += 3) if (f[i + 2] !== 'unspecified' && f[i + 2] !== 'unset') filtered.add(f[i])
339
- return filtered
340
- }
341
-
342
- /** The commit a branch points at (wherever HEAD is), or null. */
343
- export async function branchTip (root, branch) {
344
- if (!branch || !gitDir(root)) return null
345
- return ((await run(root, ['rev-parse', '--verify', '-q', `refs/heads/${branch}^{commit}`])) || '').trim() || null
346
- }
347
-
348
- /** Paths that differ between two commits, each with git's status letter (A added, M modified, D deleted...); null when git fails. */
349
- /** How many commits `to` has that `from` hasn't (what a pull brought in), or null when git can't say. */
350
- export async function commitsBetween (root, from, to) {
351
- const out = await run(root, ['rev-list', '--count', `${from}..${to}`])
352
- const n = out === null ? NaN : Number(String(out).trim())
353
- return Number.isFinite(n) ? n : null
354
- }
355
-
356
- export async function changesBetween (root, shaA, shaB) {
357
- const out = await run(root, ['diff', '--no-renames', '--no-ext-diff', '--name-status', '-z', shaA, shaB])
358
- if (out === null) return null
359
- const f = out.split('\0')
360
- const changes = new Map()
361
- for (let i = 0; i + 1 < f.length; i += 2) if (f[i + 1]) changes.set(f[i + 1], f[i][0])
362
- return changes
363
- }
364
-
365
- /**
366
- * When each path last changed in `from`..`to`, by commit time (ms): Map path -> ms, in one git log.
367
- * What "newer" means for the lines both a catch-up's commits and the session changed (upstream.js combine).
368
- */
369
- export async function commitTimes (root, from, to) {
370
- const out = await run(root, ['log', '--format=%x1e%ct', '--name-only', '--no-renames', `${from}..${to}`])
371
- const times = new Map()
372
- if (out === null) return times
373
- for (const chunk of out.split('\x1e')) {
374
- const [ct, ...names] = chunk.split('\n')
375
- const ms = Number(ct) * 1000
376
- if (!Number.isFinite(ms) || !ms) continue
377
- for (const rel of names) if (rel && !times.has(rel)) times.set(rel, ms) // newest first
378
- }
379
- return times
380
- }
381
-
382
- /** Paths that differ between two commits. */
383
- export async function changedBetween (root, shaA, shaB) {
384
- const changes = await changesBetween(root, shaA, shaB)
385
- return changes ? [...changes.keys()] : []
386
- }
387
-
388
- /**
389
- * What git says about `paths` now, in two git calls whatever their number:
390
- * `dirty` (changed, staged, untracked or conflicted), `unmerged` (a conflict
391
- * git left for the person to resolve) and `tracked` (in the index). Null when git fails.
392
- */
393
- export async function treeState (root, paths) {
394
- const spec = paths.length <= MAX_PATHSPECS ? ['--', ...paths] : []
395
- const status = await run(root, ['--literal-pathspecs', 'status', '--porcelain=v2', '-z', '--untracked-files=all', ...spec])
396
- if (status === null) return null // not asking for the second when the first failed (or timed out)
397
- const listed = await run(root, ['--literal-pathspecs', 'ls-files', '-z', ...spec])
398
- if (listed === null) return null
399
- const dirty = new Set()
400
- const unmerged = new Set()
401
- const f = status.split('\0')
402
- for (let i = 0; i < f.length; i++) {
403
- const e = f[i]
404
- if (e[0] === '1') dirty.add(e.split(' ').slice(8).join(' '))
405
- else if (e[0] === '2') dirty.add(e.split(' ').slice(9).join(' ')).add(f[++i]) // renamed: both paths
406
- else if (e[0] === 'u') { const rel = e.split(' ').slice(10).join(' '); dirty.add(rel); unmerged.add(rel) }
407
- else if (e[0] === '?' || e[0] === '!') dirty.add(e.slice(2))
408
- }
409
- return { dirty, unmerged, tracked: new Set(listed.split('\0').filter(Boolean)) }
410
- }
411
-
412
- /**
413
- * Paths with a conflict git left in the index for the person to resolve
414
- * (stages 1-3), whatever made it: a merge, a rebase, a `stash pop`, `checkout -m`.
415
- * Null when git fails.
416
- */
417
- export async function unmergedPaths (root) {
418
- const out = await run(root, ['ls-files', '-u', '-z'])
419
- if (out === null) return null
420
- const paths = new Set()
421
- for (const e of out.split('\0')) { const tab = e.indexOf('\t'); if (tab > 0) paths.add(e.slice(tab + 1)) }
422
- return paths
423
- }
424
-
425
- /** A cheap mark that changes whenever something is stashed or a stash is dropped (the stash reflog). */
426
- export function stashStamp (root) {
427
- let dir = gitDir(root)
428
- if (!dir) return null
429
- try { dir = path.resolve(dir, fs.readFileSync(path.join(dir, 'commondir'), 'utf8').trim()) } catch {} // a worktree keeps its stash with the main repo
430
- try { const st = fs.statSync(path.join(dir, 'logs', 'refs', 'stash')); return `${st.mtimeMs}:${st.size}` } catch { return null }
431
- }
432
-
433
- /**
434
- * What the last fetch brought that HEAD doesn't have yet: files the upstream
435
- * branch adds (since HEAD and it parted), as Map path -> the file there as Quilt
436
- * keys it (text, or "bin:<sha1>"; undefined when it can't be read). Limited
437
- * to `paths` when given. Empty with no upstream or nothing new; null when git fails.
438
- */
439
- export async function upstreamAdds (root, paths = null) {
440
- if (paths && !paths.length) return new Map()
441
- const up = await run(root, ['rev-parse', '-q', '--verify', '@{upstream}^{commit}'])
442
- if (up === null) return new Map() // no upstream (or none fetched): nothing is coming
443
- const sha = up.trim()
444
- const spec = paths && paths.length <= MAX_PATHSPECS ? ['--', ...paths] : []
445
- const out = await run(root, ['--literal-pathspecs', 'diff', '--name-only', '-z', '--no-renames', '--diff-filter=A', `HEAD...${sha}`, ...spec])
446
- if (out === null) return null
447
- let added = out.split('\0').filter(Boolean)
448
- if (paths && !spec.length) { const want = new Set(paths); added = added.filter((rel) => want.has(rel)) }
449
- if (!added.length) return new Map()
450
- const at = await filesAt(root, sha, added)
451
- if (!at) return null
452
- return new Map(added.map((rel) => [rel, at.get(rel)]))
453
- }
454
-
455
- /**
456
- * Before a pull: the upstream branch, how many commits behind HEAD is, and the
457
- * files those commits add that sit in this folder untracked (the session put
458
- * them here). git refuses to pull over those. Null when git fails or there is
459
- * no upstream.
460
- */
461
- export async function pullState (root) {
462
- const name = await run(root, ['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{upstream}'])
463
- if (name === null) return null
464
- const count = await run(root, ['rev-list', '--count', 'HEAD..@{upstream}'])
465
- if (count === null) return null
466
- const behind = Number(count.trim()) || 0
467
- if (!behind) return { upstream: name.trim(), behind, adds: new Map() }
468
- const adds = await upstreamAdds(root)
469
- if (!adds) return null
470
- const paths = [...adds.keys()]
471
- const listed = paths.length ? await run(root, ['--literal-pathspecs', 'ls-files', '-z', ...(paths.length <= MAX_PATHSPECS ? ['--', ...paths] : [])]) : ''
472
- if (listed === null) return null
473
- const tracked = new Set(listed.split('\0').filter(Boolean))
474
- return { upstream: name.trim(), behind, adds: new Map([...adds].filter(([rel]) => !tracked.has(rel))) }
475
- }
476
-
477
- /**
478
- * Watches HEAD, the index, a fetch (FETCH_HEAD) and the in-progress markers; events: head, index, fetch, busy, idle.
479
- * Also `ref`: a branch moved without HEAD's file changing (a commit, or another worktree of the
480
- * same repository moving this branch: `git update-ref`, `git push . x:main`, `git fetch . x:main`).
481
- */
482
- export function watchGit (root, onEvent) {
483
- const dir = gitDir(root)
484
- if (!dir) return { close: async () => {} }
485
- const names = new Set(['HEAD', 'index', 'FETCH_HEAD', ...MARKERS.map(([f]) => f)])
486
- let wasBusy = !!busy(root, dir)
487
- const watcher = watch(dir, { ignoreInitial: true, depth: 0, followSymlinks: false })
488
- // Branches live in the shared directory a worktree's .git points into (refs/heads, packed-refs).
489
- const common = commonDir(root) || dir
490
- const refs = watch([path.join(common, 'refs', 'heads'), path.join(common, 'packed-refs')], { ignoreInitial: true, followSymlinks: false })
491
- const onRef = (p) => { if (!p.endsWith('.lock')) onEvent({ type: 'ref' }) }
492
- refs.on('add', onRef).on('change', onRef).on('unlink', onRef).on('error', () => {})
493
- const onAny = (p) => {
494
- const name = path.basename(p)
495
- if (!names.has(name)) return
496
- if (name === 'HEAD') onEvent({ type: 'head' })
497
- else if (name === 'index') onEvent({ type: 'index' })
498
- else if (name === 'FETCH_HEAD') onEvent({ type: 'fetch' })
499
- else {
500
- const now = !!busy(root, dir)
501
- if (now !== wasBusy) { wasBusy = now; onEvent({ type: now ? 'busy' : 'idle' }) }
502
- }
503
- }
504
- watcher.on('add', onAny).on('change', onAny).on('unlink', onAny).on('addDir', onAny).on('unlinkDir', onAny)
505
- return { close: () => Promise.all([closeWatcher(watcher), closeWatcher(refs)]) }
506
- }
507
-
508
- /** The repository's shared git directory (a worktree's .git/worktrees/<name> points at it), or null. */
509
- export function commonDir (root) {
510
- const dir = gitDir(root)
511
- if (!dir) return null
512
- try { return path.resolve(dir, fs.readFileSync(path.join(dir, 'commondir'), 'utf8').trim()) } catch { return dir }
513
- }
514
-
515
- // ------------------------------------------------------------ upstream --
516
- // What the folder's branch has to catch up with. A fetch can wait on the network: it gets longer.
517
- export const FETCH_TIMEOUT_MS = 60 * 1000
518
-
519
- /**
520
- * The branch's upstream: { name ("origin/main"), remote ("origin"), url, sha }, or null
521
- * (no upstream set, or git failed). The address is read from the folder's own git config:
522
- * nobody sets it in Quilt.
523
- */
524
- export async function upstreamOf (root) {
525
- const name = ((await run(root, ['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{upstream}'])) || '').trim()
526
- if (!name) return null
527
- const sha = ((await run(root, ['rev-parse', '-q', '--verify', '@{upstream}^{commit}'])) || '').trim() || null
528
- const branch = headRef(root)
529
- const remote = branch ? ((await run(root, ['config', '--get', `branch.${branch}.remote`])) || '').trim() : ''
530
- // The repository as configured, not as an insteadOf rule rewrites it for transport (a mirror, an SSH rule): that is which repository it is.
531
- const url = remote && remote !== '.' ? (((await run(root, ['config', '--get', `remote.${remote}.url`])) || '').trim() || ((await run(root, ['remote', 'get-url', remote])) || '').trim()) : ''
532
- return { name, remote: remote || null, url: url || null, sha }
533
- }
534
-
535
- /**
536
- * Fetches the branch's upstream remote in the background: only remote-tracking refs move, never
537
- * a file, the index or a branch. No prompt ever waits for a password (GIT_TERMINAL_PROMPT=0, no
538
- * askpass): a remote that needs one the folder doesn't have just isn't fetched. Returns
539
- * { ok, error }: `error` says in plain English why it failed (fetchProblem), null when it worked.
540
- */
541
- export async function fetchUpstream (root, remote, url = null, { token = null } = {}) {
542
- if (!remote || remote === '.') return { ok: false, error: null, skipped: true }
543
- const r = await call(root, ['-c', 'core.askPass=', 'fetch', '--quiet', '--no-tags', '--no-write-fetch-head', '--prune', remote], {
544
- timeout: FETCH_TIMEOUT_MS,
545
- env: { GIT_TERMINAL_PROMPT: '0', GIT_ASKPASS: '', SSH_ASKPASS: '', GCM_INTERACTIVE: 'never', GIT_SSH_COMMAND: process.env.GIT_SSH_COMMAND || 'ssh -o BatchMode=yes', ...gitAuthEnv(token) }
546
- })
547
- if (r.out !== null) return { ok: true, error: null }
548
- return { ok: false, error: fetchProblem({ stderr: r.stderr, url, remote, timedOut: r.timedOut, missing: r.missing }) }
549
- }
550
-
551
- /**
552
- * The remote URL `url` with its GitHub repository changed to `to` (`github.com/<owner>/<name>`), in the
553
- * same form (https, scp-like ssh or ssh://), when the repository it names is one of `was` (its earlier
554
- * names: renamed or moved on GitHub). Null when it names another repository or isn't GitHub's.
555
- */
556
- export function movedRemoteUrl (url, was, to) {
557
- const m = /^(https:\/\/(?:[^@/]+@)?github\.com\/|git@github\.com:|ssh:\/\/git@github\.com\/)([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+?)(\.git)?\/?$/.exec(String(url || ''))
558
- const t = /^github\.com\/([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+)$/.exec(String(to || ''))
559
- if (!m || !t) return null
560
- const slug = `github.com/${m[2]}/${m[3]}`.toLowerCase()
561
- if (!(was || []).some((w) => String(w).toLowerCase() === slug)) return null
562
- return `${m[1]}${t[1]}/${t[2]}${m[4] || ''}`
563
- }
564
-
565
- /**
566
- * fetchUpstream signed in as well as this folder can be: with the token it `kept` (the session's
567
- * GitHub connection), else the folder's own sign-in. A kept token that stopped working (the
568
- * repository moved to another owner, the app lost access) is dropped (`drop`), the folder's own
569
- * sign-in is tried, then a `fresh` token. `retry` false (a remote that isn't GitHub): one try.
570
- */
571
- export async function fetchSignedIn (root, up, { kept = null, fresh = async () => null, drop = () => {}, retry = true } = {}) {
572
- let f = await fetchUpstream(root, up.remote, up.url, { token: kept })
573
- if (f.ok || f.skipped || !retry) return f
574
- if (kept) { drop(); f = await fetchUpstream(root, up.remote, up.url) }
575
- if (f.ok || f.skipped) return f
576
- const token = await fresh()
577
- return token && token !== kept ? fetchUpstream(root, up.remote, up.url, { token }) : f
578
- }
579
-
580
- /**
581
- * Sign-in for one git command over https with a GitHub token (Quilt's app, read only: see
582
- * relay-github.js), as environment rather than arguments, so it never shows in a process list.
583
- */
584
- export function gitAuthEnv (token, { user = 'x-access-token' } = {}) {
585
- if (!token) return {}
586
- return { GIT_CONFIG_COUNT: '1', GIT_CONFIG_KEY_0: 'http.extraheader', GIT_CONFIG_VALUE_0: `Authorization: Basic ${Buffer.from(`${user}:${token}`).toString('base64')}` }
587
- }
588
-
589
- /**
590
- * Makes a folder that holds a branch's files (an agent's copy, synced by Quilt) a git checkout of
591
- * that branch at commit `sha`, with every file left as it is: init, fetch `ref` from `url` (with
592
- * `token` when the repository is private), the branch at `sha`, the index at `sha`, upstream set.
593
- * The session's uncommitted work then shows as the working tree's changes. Resolves { ok } or
594
- * { ok: false, error }; a failure leaves no .git behind.
595
- */
596
- export async function backWithGit (root, { url, ref, branch = ref, sha, token = null, user = 'x-access-token', helpers = null }) {
597
- if (fs.existsSync(path.join(root, '.git'))) return { ok: true, had: true }
598
- const env = { GIT_TERMINAL_PROMPT: '0', GIT_ASKPASS: '', SSH_ASKPASS: '', GCM_INTERACTIVE: 'never', ...gitAuthEnv(token, { user }) }
599
- const undo = () => { try { fs.rmSync(path.join(root, '.git'), { recursive: true, force: true }) } catch {} }
600
- const step = async (args, what, opts = {}) => {
601
- const r = await call(root, args, { timeout: FETCH_TIMEOUT_MS * 10, env, ...opts })
602
- if (r.out === null) { undo(); throw new Error(`${what}: ${r.missing ? "git can't be run here" : r.timedOut ? 'took too long' : (r.stderr || 'failed').split('\n').find(Boolean)}`) }
603
- return r.out
604
- }
605
- try {
606
- await step(['init', '-q', '-b', branch], 'git init')
607
- await step(['remote', 'add', 'origin', url], 'adding the remote')
608
- await step(['config', 'remote.origin.fetch', `+refs/heads/*:refs/remotes/origin/*`], 'remote config')
609
- await step(['-c', 'core.askPass=', 'fetch', '--quiet', '--no-tags', 'origin', ref], `fetching ${ref}`)
610
- // The commit the session's files are at may be behind the branch's tip (or on it): make sure it's here.
611
- if ((await call(root, ['cat-file', '-e', `${sha}^{commit}`], { env })).out === null) await step(['-c', 'core.askPass=', 'fetch', '--quiet', '--no-tags', 'origin', sha], `fetching ${sha.slice(0, 7)}`)
612
- await step(['update-ref', `refs/heads/${branch}`, sha], 'setting the branch')
613
- await step(['symbolic-ref', 'HEAD', `refs/heads/${branch}`], 'checking out the branch')
614
- await step(['read-tree', '--reset', sha], 'setting the index')
615
- await step(['branch', `--set-upstream-to=origin/${ref}`, branch], 'tracking the upstream')
616
- const exclude = path.join(root, '.git', 'info', 'exclude')
617
- fs.mkdirSync(path.dirname(exclude), { recursive: true })
618
- fs.appendFileSync(exclude, '.quilt/\n.agent-scratch/\n')
619
- if (helpers) { try { await installGitHelpers(root, { ...helpers, host: remoteHost(url), origin: urlOrigin(url) }) } catch {} }
620
- return { ok: true }
621
- } catch (err) { undo(); return { ok: false, error: err.message } }
622
- }
623
-
624
- /** A string as one word for sh (the credential helper and the hook run through it). */
625
- export const shellQuote = (s) => `'${String(s).replace(/'/g, "'\\''")}'`
626
-
627
- const HOOK_MARK = '# Quilt pre-push hook'
628
-
629
- /**
630
- * The Quilt CLI as git runs it (through sh: a helper starting with !, and the hook). In the desktop
631
- * app `node` is the app itself, which without ELECTRON_RUN_AS_NODE opens as the app and never
632
- * answers, so git waited for ever; plain node ignores it.
633
- */
634
- // `script` empty: `node` is a command that runs Quilt itself (a cloud AI machine's launcher).
635
- const cliRun = (node, script) => `ELECTRON_RUN_AS_NODE=1 ${shellQuote(node)}${script ? ` ${shellQuote(script)}` : ''}`
636
-
637
- /**
638
- * Lets an agent's folder use git like anyone: git asks Quilt for credentials for `host`
639
- * (`quilt git-credential`, which asks the running session, which asks the relay for a short-lived
640
- * token for this repository, write access within the owner's policy), a pre-push hook
641
- * (`quilt git-pre-push`) holds pushes to that policy and tells the session what was pushed, and
642
- * commits are credited to the agent (`identity`: { name, email }, unless the folder has its own).
643
- * `node` and `script` run the CLI (`node` alone when it is a launcher). Idempotent; a hook that is someone else's is left alone, and a
644
- * folder with core.hooksPath gets no hook. Returns { helper, hook } (what is in place).
645
- */
646
- export async function installGitHelpers (root, { node, script, host = 'github.com', origin = '', identity = null }) {
647
- const dir = gitDir(root)
648
- if (!dir || !node) return { helper: false, hook: false }
649
- const cmd = (sub) => `!${cliRun(node, script)} ${sub} --dir ${shellQuote(root)}`
650
- let helper = false
651
- if (host) {
652
- // `origin`: the remote's scheme, host and port (the relay's git, http:// in tests); else https://<host>.
653
- const key = `credential.${origin || `https://${host}`}.helper`
654
- const have = String((await call(root, ['config', '--local', '--get-all', key])).out || '').replace(/\n$/, '').split('\n')
655
- const want = cmd('git-credential')
656
- // Exactly: an empty helper first (whatever is configured globally for this host, a keychain with a
657
- // person's login, is not asked in an agent's folder), then Quilt's. Anything else (an older path to node or Quilt) is replaced.
658
- if (have.length !== 2 || have[0] !== '' || have[1] !== want) {
659
- await call(root, ['config', '--local', '--replace-all', key, ''])
660
- await call(root, ['config', '--local', '--add', key, want])
661
- }
662
- helper = true
663
- }
664
- if (identity && identity.name && identity.email) {
665
- if (!(await call(root, ['config', '--local', 'user.name'])).out) await call(root, ['config', '--local', 'user.name', identity.name])
666
- if (!(await call(root, ['config', '--local', 'user.email'])).out) await call(root, ['config', '--local', 'user.email', identity.email])
667
- }
668
- const hook = await writeQuiltHook(root, { node, script })
669
- return { helper, hook }
670
- }
671
-
672
- /**
673
- * Quilt's pre-push hook in the folder's own hooks (a hook that is someone else's is left alone, and a
674
- * folder with core.hooksPath gets none). Returns whether it is in place.
675
- */
676
- async function writeQuiltHook (root, { node, script }) {
677
- const dir = gitDir(root)
678
- if (!dir) return false
679
- const hooksPath = (await call(root, ['config', 'core.hooksPath'])).out
680
- if (hooksPath) return false
681
- const file = path.join(dir, 'hooks', 'pre-push')
682
- let now = ''
683
- try { now = fs.readFileSync(file, 'utf8') } catch {}
684
- if (now && !now.includes(HOOK_MARK)) return false
685
- const want = `#!/bin/sh\n${HOOK_MARK}: pushes from this folder follow the session's git setting (git for everyone; for agents, quilt_agent_commits), and the session is told what was pushed.\n${cliRun(node, script).replace(/ /, ' exec ')} git-pre-push --dir ${shellQuote(root)} "$@"\n`
686
- if (now !== want) {
687
- fs.mkdirSync(path.dirname(file), { recursive: true })
688
- fs.writeFileSync(file, want, { mode: 0o755 })
689
- try { fs.chmodSync(file, 0o755) } catch {}
690
- }
691
- return true
692
- }
693
-
694
- /** The credential sections in the folder's own config whose helper is Quilt's for this folder: [credential.<url>.helper keys]. */
695
- async function quiltHelperKeys (root) {
696
- const out = (await call(root, ['config', '--local', '--get-regexp', '^credential\\..*\\.helper$'])).out || ''
697
- const keys = new Set()
698
- for (const line of out.split('\n')) {
699
- const i = line.indexOf(' ')
700
- if (i < 0) continue
701
- if (/ git-credential --dir /.test(line.slice(i + 1))) keys.add(line.slice(0, i))
702
- }
703
- return [...keys]
704
- }
705
-
706
- /**
707
- * Git for everyone, in a person's own folder (docs/superpowers/specs/2026-10-17-git-for-everyone.md):
708
- * Quilt's credential helper for the session's repository only (`url`, as git asks for it: the remote's
709
- * URL with insteadOf applied), in the folder's own config: an empty helper first, so the person's other
710
- * helpers (a keychain) are set aside for that URL, then `quilt git-credential --fallback`, which asks
711
- * them anyway when no Quilt session answers. `helper: false` takes Quilt's helper away (any URL);
712
- * `hook` puts the pre-push hook in, or takes it away. Global config and keychains are never touched.
713
- * Returns { helper, hook } (what is in place now).
714
- */
715
- export async function setPersonGit (root, { node, script, url = '', helper = false, hook = false }) {
716
- if (!gitDir(root) || !node) return { helper: false, hook: false }
717
- const key = helper && url ? `credential.${url}.helper` : null
718
- // Quilt's helper under any other URL (the remote moved, or git turned off) goes.
719
- for (const k of await quiltHelperKeys(root)) if (k !== key) await call(root, ['config', '--local', '--unset-all', k])
720
- let helperOn = false
721
- if (key) {
722
- const want = `!${cliRun(node, script)} git-credential --dir ${shellQuote(root)} --fallback`
723
- const have = String((await call(root, ['config', '--local', '--get-all', key])).out || '').replace(/\n$/, '').split('\n')
724
- if (have.length !== 2 || have[0] !== '' || have[1] !== want) {
725
- await call(root, ['config', '--local', '--replace-all', key, ''])
726
- await call(root, ['config', '--local', '--add', key, want])
727
- }
728
- helperOn = true
729
- }
730
- let hookOn = false
731
- if (hook) hookOn = await writeQuiltHook(root, { node, script })
732
- else removeQuiltHook(root)
733
- return { helper: helperOn, hook: hookOn }
734
- }
735
-
736
- /** Takes Quilt's pre-push hook out of the folder (only Quilt's: a hook of the person's own stays). */
737
- export function removeQuiltHook (root) {
738
- const dir = gitDir(root)
739
- if (!dir) return
740
- const file = path.join(dir, 'hooks', 'pre-push')
741
- try { if (fs.readFileSync(file, 'utf8').includes(HOOK_MARK)) fs.rmSync(file, { force: true }) } catch {}
742
- }
743
-
744
- /** scheme://host[:port] of an http(s) remote URL, or '' (a path, ssh). */
745
- export function urlOrigin (url) {
746
- try { const u = new URL(String(url || '')); return /^https?:$/.test(u.protocol) ? u.origin : '' } catch { return '' }
747
- }
748
-
749
- /** The host a remote URL points at ("github.com"), or null for a local path. */
750
- export function remoteHost (url) {
751
- if (typeof url !== 'string' || !url) return null
752
- const scp = /^[A-Za-z0-9._-]+@([A-Za-z0-9.-]+):(?!\/)/.exec(url)
753
- if (scp) return scp[1].toLowerCase()
754
- try { const u = new URL(url); return u.protocol === 'file:' ? null : (u.hostname || null) } catch { return null }
755
- }
756
-
757
- /**
758
- * Why `git fetch` failed, in plain English, from what git printed: "git can't sign in to
759
- * github.com from this folder", "can't reach github.com (no network?)", and so on. Never the
760
- * URL itself (it may carry credentials).
761
- */
762
- export function fetchProblem ({ stderr = '', url = null, remote = 'origin', timedOut = false, missing = false } = {}) {
763
- const host = remoteHost(url) || remote || 'the remote'
764
- const e = String(stderr || '').toLowerCase()
765
- if (missing) return "git can't be run here"
766
- if (timedOut) return `fetching from ${host} took over a minute and was stopped`
767
- if (/does not appear to be a git repository|not a git repository|no such remote/.test(e)) return `${remote} isn't a repository git can fetch from`
768
- if (/could not read (username|password)|terminal prompts disabled|authentication failed|permission denied \(publickey|host key verification failed|invalid username or password|access denied|returned error: 40[13]|support for password authentication was removed|repository not found|could not read from remote repository/.test(e)) {
769
- return `git can't sign in to ${host} from this folder`
770
- }
771
- if (/could not resolve host|failed to connect|connection (timed out|refused)|network is unreachable|operation timed out|couldn't connect|unable to access|no route to host|temporary failure in name resolution/.test(e)) {
772
- return `can't reach ${host} (no network?)`
773
- }
774
- const line = String(stderr || '').split('\n').map((l) => l.replace(/^(fatal|error):\s*/i, '').trim()).find(Boolean)
775
- return line ? `git fetch failed: ${line.replace(/[a-z]+:\/\/[^\s'"]*@/gi, '').slice(0, 120)}` : 'git fetch failed'
776
- }
777
-
778
- /** Whether the repository has commit `sha` (fetched or made here). */
779
- export async function hasCommit (root, sha) {
780
- if (!sha) return false
781
- return (await call(root, ['cat-file', '-e', `${sha}^{commit}`])).out !== null
782
- }
783
-
784
- /** Whether commit a is an ancestor of (or the same as) b; null when git can't say. */
785
- export async function isAncestor (root, a, b) {
786
- const r = await call(root, ['merge-base', '--is-ancestor', a, b])
787
- if (r.out !== null) return true
788
- if (r.missing || r.timedOut) return null
789
- // Exit 1 is "no"; anything else (an unknown commit) is "can't say".
790
- const known = await run(root, ['cat-file', '-e', `${a}^{commit}`]) !== null && await run(root, ['cat-file', '-e', `${b}^{commit}`]) !== null
791
- return known ? false : null
792
- }
793
-
794
- /** Whether the index has changes staged against `sha`; null when git can't say. */
795
- export async function stagedAgainst (root, sha) {
796
- const r = await call(root, ['diff-index', '--cached', '--quiet', '--ignore-submodules', sha, '--'])
797
- if (r.out !== null) return false
798
- return r.missing || r.timedOut ? null : true
799
- }
800
-
801
- /**
802
- * Moves the folder's branch from `from` to `to` (`branch` null: it is there already) and sets the
803
- * index to `to`, leaving every file as it is; `during()` (writing `to`'s files: see catchUp) runs first.
804
- * git's index lock is held throughout, so a person's `git commit` meanwhile either went first (the
805
- * branch is no longer at `from`: not moved) or is refused by git and asked again. The index is set
806
- * before the branch moves: git reads HEAD, then the index, and only then takes the lock, so a commit
807
- * that read the new HEAD reads the new index too, and one that read the old HEAD fails git's own check
808
- * that HEAD hasn't moved. Never a commit on `to` made from the old index or missing `to`'s files,
809
- * which would undo `to`'s changes on GitHub. Returns whether it moved; `fastForward.indexLate` is set
810
- * when the index could not follow yet.
811
- */
812
- export async function fastForward (root, branch, from, to, during = null) {
813
- const lock = await lockIndex(root)
814
- if (!lock) return false
815
- try {
816
- if (branch && ((await run(root, ['rev-parse', '-q', '--verify', `refs/heads/${branch}`])) || '').trim() !== from) return false
817
- if (during) await during()
818
- const r = await moveUnderLock(root, lock, branch, from, to, 'quilt: catch up with upstream')
819
- fastForward.indexLate = r.indexLate
820
- return r.moved
821
- } finally { lock.release() }
822
- }
823
-
824
- /**
825
- * Under the index lock: the index becomes `to`'s, then `branch` moves from `from` to `to` (see
826
- * fastForward for why in that order). When the branch moved meanwhile, the index goes back as it was.
827
- * When git can't set the index, the branch moves all the same and { indexLate } says so (fixIndex sets it).
828
- */
829
- async function moveUnderLock (root, lock, branch, from, to, reason) {
830
- const kept = branch ? lock.keep() : false
831
- const indexSet = await lock.setIndex(to)
832
- if (branch && (await call(root, ['update-ref', '-m', reason, `refs/heads/${branch}`, to, from])).out === null) {
833
- if (indexSet && kept) lock.putBack()
834
- return { moved: false, indexLate: false }
835
- }
836
- return { moved: true, indexLate: !indexSet }
837
- }
838
-
839
- /**
840
- * Takes git's index lock (index.lock) as git itself does, asking again for up to two seconds while
841
- * another git holds it; null when it can't. While it is held, `setIndex(sha)` makes the index
842
- * `sha`'s tree (keeping what git knows of files that didn't change) and `release()` lets git in again.
843
- */
844
- export async function lockIndex (root) {
845
- const dir = gitDir(root)
846
- if (!dir) return null
847
- const index = path.join(dir, 'index')
848
- const lock = `${index}.lock`
849
- const prev = `${index}.quilt-prev-${process.pid}`
850
- for (let i = 0; i < 20; i++) {
851
- try {
852
- fs.closeSync(fs.openSync(lock, 'wx'))
853
- ownLocks.add(lock)
854
- return {
855
- async setIndex (sha) {
856
- const next = `${index}.quilt-${process.pid}`
857
- try { fs.copyFileSync(index, next) } catch {}
858
- const r = await call(root, ['read-tree', '--reset', sha], { env: { GIT_INDEX_FILE: next } })
859
- try {
860
- if (r.out === null) return false
861
- fs.renameSync(next, index)
862
- return true
863
- } catch { return false } finally { fs.rmSync(next, { force: true }); fs.rmSync(`${next}.lock`, { force: true }) }
864
- },
865
- // A copy of the index as it is, put back by putBack() (the branch it was for didn't move after all).
866
- keep () { try { fs.copyFileSync(index, prev); return true } catch { return false } },
867
- putBack () { try { fs.renameSync(prev, index) } catch {} },
868
- release () { fs.rmSync(prev, { force: true }); fs.rmSync(lock, { force: true }); ownLocks.delete(lock) }
869
- }
870
- } catch (err) { if (err.code !== 'EEXIST') return null }
871
- await new Promise((resolve) => setTimeout(resolve, 100))
872
- }
873
- return null
874
- }
875
-
876
- /**
877
- * Every branch and worktree of the repository, as seen from this folder:
878
- * { worktrees: [{ path, branch, sha }], branches: [{ name, sha, upstream, ahead, behind, author, ts, subject }] }.
879
- * Null when git fails. Worktrees share the repository, so one folder sees them all.
880
- */
881
- export async function repoBranches (root) {
882
- const wt = await run(root, ['worktree', 'list', '--porcelain'])
883
- if (wt === null) return null
884
- const worktrees = []
885
- let cur = null
886
- for (const line of wt.split('\n')) {
887
- if (line.startsWith('worktree ')) { cur = { path: line.slice(9), branch: null, sha: null }; worktrees.push(cur) } else if (cur && line.startsWith('HEAD ')) { cur.sha = line.slice(5) } else if (cur && line.startsWith('branch ')) { cur.branch = line.slice(7).replace(/^refs\/heads\//, '') }
888
- }
889
- const fmt = '%(refname:short)%00%(objectname)%00%(upstream:short)%00%(upstream:track,nobracket)%00%(authorname)%00%(committerdate:unix)%00%(contents:subject)'
890
- const refs = await run(root, ['for-each-ref', '--sort=-committerdate', '--count=200', `--format=${fmt}`, 'refs/heads'])
891
- if (refs === null) return null
892
- const branches = []
893
- for (const line of refs.split('\n')) {
894
- if (!line) continue
895
- const [name, sha, upstream, track, author, ts, subject] = line.split('\0')
896
- const n = (word) => { const m = new RegExp(`${word} (\\d+)`).exec(track || ''); return m ? Number(m[1]) : 0 }
897
- branches.push({ name, sha, upstream: upstream || null, ahead: n('ahead'), behind: n('behind'), gone: /gone/.test(track || ''), author, ts: Number(ts) * 1000 || 0, subject })
898
- }
899
- return { worktrees, branches }
900
- }
901
-
902
- /** A file's bytes at a commit as checkout would write them (--filters), or null. */
903
- /**
904
- * Whether `text` is the file `rel` as committed at `sha`, as git sees it: what git would store for it
905
- * (its clean conversion for that path: line endings under core.autocrlf or an eol attribute) is the
906
- * commit's blob. filesAt reads a commit as checkout would write it, so where git converts line endings
907
- * an LF copy of a committed file differs from it byte for byte, though git calls it committed. Null when
908
- * git fails.
909
- */
910
- export async function committedAs (root, sha, rel, text) {
911
- const [blob, hashed] = await Promise.all([
912
- run(root, ['rev-parse', '--verify', '--quiet', `${sha}:${rel}`]),
913
- run(root, ['hash-object', `--path=${rel}`, '--stdin'], { input: text })
914
- ])
915
- if (blob === null || hashed === null) return null
916
- return blob.trim() !== '' && blob.trim() === hashed.trim()
917
- }
918
-
919
- /** Which of `paths` are links (mode 120000) at commit `sha`: a Set, or null when git can't say. */
920
- export async function linksAt (root, sha, paths) {
921
- if (!paths.length) return new Set()
922
- const out = await run(root, ['ls-tree', '-z', sha, '--', ...paths])
923
- if (out === null) return null
924
- return new Set(out.split('\0').filter((l) => l.startsWith('120000 ')).map((l) => l.slice(l.indexOf('\t') + 1)))
925
- }
926
-
927
- /**
928
- * The paths a folder's branch changed since it left the remote's default branch: `committed`, from
929
- * the merge base of HEAD and origin/HEAD (else origin/main, origin/master) to HEAD; `uncommitted`,
930
- * what differs from HEAD (staged or not, deletions too) and files git does not track and does not
931
- * ignore. `gitDir` reads a .git kept elsewhere (set aside while the folder follows a square). Two
932
- * Sets, or null when git can't say (no remote default branch, no common history).
933
- */
934
- export async function ownSinceFork (root, { gitDir = null } = {}) {
935
- const opts = gitDir ? { env: { GIT_DIR: gitDir, GIT_WORK_TREE: root } } : {}
936
- let main = ''
937
- for (const ref of ['refs/remotes/origin/HEAD', 'refs/remotes/origin/main', 'refs/remotes/origin/master']) {
938
- main = ((await run(root, ['rev-parse', '-q', '--verify', `${ref}^{commit}`], opts)) || '').trim()
939
- if (main) break
940
- }
941
- if (!main) return null
942
- const fork = ((await run(root, ['merge-base', 'HEAD', main], opts)) || '').trim()
943
- if (!fork) return null
944
- const lists = [
945
- await run(root, ['diff', '--no-renames', '--no-ext-diff', '--name-only', '-z', fork, 'HEAD'], opts),
946
- await run(root, ['diff', '--no-renames', '--no-ext-diff', '--name-only', '-z', 'HEAD'], opts),
947
- await run(root, ['ls-files', '--others', '--exclude-standard', '--exclude=/.quilt/', '-z'], opts)
948
- ]
949
- if (lists.some((l) => l === null)) return null
950
- const names = (l) => l.split('\0').filter(Boolean)
951
- return { committed: new Set(names(lists[0])), uncommitted: new Set([...names(lists[1]), ...names(lists[2])]) }
952
- }
953
-
954
- /** A file's bytes at commit `sha` (null when git can't say); `gitDir` reads from a .git kept elsewhere (set aside while the folder follows a square). */
955
- export async function blobAt (root, sha, rel, { gitDir = null } = {}) {
956
- const out = await run(root, ['cat-file', '--filters', `${sha}:${rel}`], { buffer: true, ...(gitDir ? { env: { GIT_DIR: gitDir, GIT_WORK_TREE: root } } : {}) })
957
- return out === null ? null : out
958
- }
959
-
960
- /** Whether the commit has any file under `dir` ('' is the top), or null when git fails. */
961
- export async function hasFilesUnder (root, sha, dir) {
962
- const out = await run(root, ['--literal-pathspecs', 'ls-tree', '-r', '--name-only', sha, '--', dir || '.'])
963
- return out === null ? null : out.trim().length > 0
964
- }
965
-
966
- /**
967
- * Sets the index to `sha` (what HEAD now points at), leaving every file as it is. Another git
968
- * (an AI's `git status`) may hold the index lock a moment: asked again for up to two seconds.
969
- */
970
- export async function resetIndex (root, sha) {
971
- for (let i = 0; i < 20; i++) {
972
- const r = await call(root, ['read-tree', '--reset', sha])
973
- if (r.out !== null) return true
974
- if (r.missing || r.timedOut) return false
975
- await new Promise((resolve) => setTimeout(resolve, 100))
976
- }
977
- return false
978
- }
979
-
980
- /**
981
- * Pushes `sha` to `remote`'s `branch` with the folder's own git and credentials: forward only (never
982
- * forced), with no prompt waiting for a password. { ok, error }.
983
- */
984
- export async function pushLocal (root, remote, sha, branch) {
985
- const r = await call(root, ['-c', 'core.askPass=', 'push', '--quiet', '--no-verify', remote, `${sha}:refs/heads/${branch}`], {
986
- timeout: FETCH_TIMEOUT_MS,
987
- env: { GIT_TERMINAL_PROMPT: '0', GIT_ASKPASS: '', SSH_ASKPASS: '', GCM_INTERACTIVE: 'never', GIT_SSH_COMMAND: process.env.GIT_SSH_COMMAND || 'ssh -o BatchMode=yes' }
988
- })
989
- return r.out !== null ? { ok: true, error: null } : { ok: false, error: (r.stderr || '').trim().split('\n').filter(Boolean).pop() || 'git push failed' }
990
- }
991
-
992
- /** The commits on `branch` that `upstream` hasn't, as a git bundle (a Buffer), or null. */
993
- export async function bundleOf (root, branch, upstream) {
994
- const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'quilt-bundle-'))
995
- try {
996
- const file = path.join(dir, 'local.bundle')
997
- const r = await call(root, ['bundle', 'create', '--quiet', file, `refs/heads/${branch}`, ...(upstream ? [`^${upstream}`] : [])], { timeout: FETCH_TIMEOUT_MS })
998
- if (r.out === null) return null
999
- return fs.readFileSync(file)
1000
- } catch { return null } finally { fs.rmSync(dir, { recursive: true, force: true }) }
1001
- }
1002
-
1003
- /**
1004
- * The folder's branch moves to `to` (GitHub's head, which now carries this folder's local commits,
1005
- * published or replayed by the relay) from `from`, its local commits kept under
1006
- * refs/quilt/original/<branch>/<time> first. Only the branch and the index move, never a file; and
1007
- * only when it is safe: HEAD still on the branch at `from`, nothing staged, no git operation under
1008
- * way. Returns { moved, why }.
1009
- */
1010
- export async function adoptPublished (root, branch, from, to) {
1011
- if (busy(root)) return { moved: false, why: 'a git operation is under way here' }
1012
- const head = ((await run(root, ['rev-parse', '-q', '--verify', 'HEAD'])) || '').trim()
1013
- if (head !== from || headRef(root) !== branch) return { moved: false, why: 'the branch moved meanwhile' }
1014
- const staged = await stagedAgainst(root, from)
1015
- if (staged !== false) return { moved: false, why: 'changes are staged for a commit here' }
1016
- const keep = await call(root, ['update-ref', '-m', 'quilt: local commits, published', `refs/quilt/original/${branch}/${Date.now()}`, from])
1017
- if (keep.out === null) return { moved: false, why: 'git could not keep the local commits' }
1018
- // Under the index lock, the index first, as fastForward: no commit made on `to` from the old index.
1019
- const lock = await lockIndex(root)
1020
- if (!lock) return { moved: false, why: 'another git holds the index' }
1021
- try {
1022
- const r = await moveUnderLock(root, lock, branch, from, to, 'quilt: follow the published commits')
1023
- return r.moved ? { moved: true, why: null } : { moved: false, why: 'the branch moved meanwhile' }
1024
- } finally { lock.release() }
1025
- }
1026
-
1027
- /**
1028
- * The commit a new branch's document is measured from when it holds only what differs from git
1029
- * (branch-view.js): the newest commit of HEAD's history that a remote has, so the relay can read it
1030
- * from GitHub. HEAD itself when a remote branch has it; else where HEAD meets its upstream, or the
1031
- * remote's default branch. Null without a remote (or when git can't say).
1032
- */
1033
- export async function pushedBase (root) {
1034
- const head = ((await run(root, ['rev-parse', '-q', '--verify', 'HEAD^{commit}'])) || '').trim()
1035
- if (!head) return null
1036
- const on = await run(root, ['branch', '-r', '--contains', head, '--format=%(refname)'])
1037
- if (on === null) return null
1038
- if (on.split('\n').some((r) => r.trim() && !r.trim().endsWith('/HEAD'))) return head
1039
- for (const other of ['@{upstream}', 'refs/remotes/origin/HEAD', 'refs/remotes/origin/main', 'refs/remotes/origin/master']) {
1040
- const sha = ((await run(root, ['rev-parse', '-q', '--verify', `${other}^{commit}`])) || '').trim()
1041
- if (!sha) continue
1042
- const mb = ((await run(root, ['merge-base', head, sha])) || '').trim()
1043
- if (/^[0-9a-f]{40,64}$/.test(mb)) return mb
1044
- }
1045
- return null
1046
- }
1047
-
1048
- /**
1049
- * What the folder has that differs from commit `sha` (its working tree, local commits included):
1050
- * { changed: Set (modified, added, or a new file git doesn't track), deleted: Set } of paths. Null
1051
- * when git fails. Untracked files git ignores are left out (the caller's own rules apply on top).
1052
- */
1053
- export async function changedSince (root, sha) {
1054
- const diff = await run(root, ['--literal-pathspecs', 'diff', '--name-status', '-z', '--no-renames', sha, '--'])
1055
- if (diff === null) return null
1056
- const others = await run(root, ['ls-files', '--others', '--exclude-standard', '-z'])
1057
- if (others === null) return null
1058
- const changed = new Set()
1059
- const deleted = new Set()
1060
- const f = diff.split('\0')
1061
- for (let i = 0; i + 1 < f.length; i += 2) {
1062
- const st = f[i][0]
1063
- const rel = f[i + 1]
1064
- if (!st || !rel) continue
1065
- if (st === 'D') deleted.add(rel); else changed.add(rel)
1066
- }
1067
- for (const rel of others.split('\0')) if (rel) changed.add(rel)
1068
- return { changed, deleted }
1069
- }