heyquilt 0.0.1 → 0.3.90

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (204) hide show
  1. package/LICENSE +85 -0
  2. package/README.md +630 -2
  3. package/RELEASES.md +733 -0
  4. package/assets/logo.svg +1 -0
  5. package/bin/quilt.js +816 -0
  6. package/desktop/main.js +315 -0
  7. package/package.json +163 -7
  8. package/src/account.js +563 -0
  9. package/src/admit-policy.js +32 -0
  10. package/src/agent-join.js +370 -0
  11. package/src/agent-task-workflow.js +176 -0
  12. package/src/agents/actions.js +85 -0
  13. package/src/agents/claude-code.js +180 -0
  14. package/src/agents/common.js +21 -0
  15. package/src/agents/cursor-transcripts.js +170 -0
  16. package/src/agents/cursor.js +404 -0
  17. package/src/agents/index.js +69 -0
  18. package/src/ai-vm-signin.js +98 -0
  19. package/src/ai-vm.js +380 -0
  20. package/src/api/access.js +75 -0
  21. package/src/api/activity.js +181 -0
  22. package/src/api/agent-auth.js +130 -0
  23. package/src/api/agent-placement.js +157 -0
  24. package/src/api/audit.js +83 -0
  25. package/src/api/auth.js +17 -0
  26. package/src/api/domains.js +30 -0
  27. package/src/api/file-paths.js +40 -0
  28. package/src/api/file-store.js +99 -0
  29. package/src/api/fly-machines.js +76 -0
  30. package/src/api/github-app.js +172 -0
  31. package/src/api/http.js +34 -0
  32. package/src/api/invite-email.js +53 -0
  33. package/src/api/issues.js +110 -0
  34. package/src/api/join-text.js +78 -0
  35. package/src/api/mailer.js +47 -0
  36. package/src/api/memory-store.js +1083 -0
  37. package/src/api/org-access.js +54 -0
  38. package/src/api/permissions.js +71 -0
  39. package/src/api/relay-cluster.js +251 -0
  40. package/src/api/relay-machines.js +60 -0
  41. package/src/api/routes/access-types.js +54 -0
  42. package/src/api/routes/agent-invites.js +115 -0
  43. package/src/api/routes/agents.js +157 -0
  44. package/src/api/routes/ai-vm.js +369 -0
  45. package/src/api/routes/github.js +380 -0
  46. package/src/api/routes/grants.js +70 -0
  47. package/src/api/routes/invites.js +188 -0
  48. package/src/api/routes/issues.js +47 -0
  49. package/src/api/routes/join.js +167 -0
  50. package/src/api/routes/members.js +110 -0
  51. package/src/api/routes/orgs.js +177 -0
  52. package/src/api/routes/relay-cluster.js +90 -0
  53. package/src/api/routes/relay.js +76 -0
  54. package/src/api/routes/session-invites.js +119 -0
  55. package/src/api/routes/sessions.js +92 -0
  56. package/src/api/routes/teams.js +132 -0
  57. package/src/api/routes/workspace-agents.js +332 -0
  58. package/src/api/routes/workspace-files.js +277 -0
  59. package/src/api/routes/workspace-invites.js +196 -0
  60. package/src/api/routes/workspaces.js +225 -0
  61. package/src/api/server.js +623 -0
  62. package/src/api/slugs.js +20 -0
  63. package/src/api/supabase-store.js +765 -0
  64. package/src/api/team-access.js +30 -0
  65. package/src/api/tokens.js +31 -0
  66. package/src/api/workspace-access.js +62 -0
  67. package/src/api/workspace-reach.js +60 -0
  68. package/src/autowork-tools.js +166 -0
  69. package/src/autowork.js +387 -0
  70. package/src/blobstore.js +99 -0
  71. package/src/boxes.js +37 -0
  72. package/src/branchdocs.js +235 -0
  73. package/src/branches.js +194 -0
  74. package/src/catchup.js +111 -0
  75. package/src/chat-archive.js +85 -0
  76. package/src/chat-links.js +923 -0
  77. package/src/clash.js +140 -0
  78. package/src/cloud-ai.js +50 -0
  79. package/src/commit-request.js +59 -0
  80. package/src/commit.js +213 -0
  81. package/src/connection.js +569 -0
  82. package/src/control.js +245 -0
  83. package/src/conversation.js +125 -0
  84. package/src/doctor.js +156 -0
  85. package/src/duties.js +303 -0
  86. package/src/editors.js +228 -0
  87. package/src/fsutil.js +87 -0
  88. package/src/git-hook.js +48 -0
  89. package/src/git.js +147 -0
  90. package/src/github-commit.js +320 -0
  91. package/src/gitignore.js +49 -0
  92. package/src/gitstate.js +900 -0
  93. package/src/history.js +307 -0
  94. package/src/hooks.js +371 -0
  95. package/src/hosted-agents.js +24 -0
  96. package/src/hosted-client.js +112 -0
  97. package/src/identity.js +99 -0
  98. package/src/inbox.js +237 -0
  99. package/src/integrations.js +221 -0
  100. package/src/key-cli.js +115 -0
  101. package/src/largefiles.js +91 -0
  102. package/src/legacy.js +25 -0
  103. package/src/mcp-proxy.js +40 -0
  104. package/src/mcp-room.js +32 -0
  105. package/src/mcp.js +1261 -0
  106. package/src/merge3.js +158 -0
  107. package/src/merges.js +148 -0
  108. package/src/pass-source.js +176 -0
  109. package/src/passes.js +64 -0
  110. package/src/pathrules.js +127 -0
  111. package/src/persona.js +101 -0
  112. package/src/presence.js +360 -0
  113. package/src/private-file.js +41 -0
  114. package/src/procs.js +70 -0
  115. package/src/project-rules.js +178 -0
  116. package/src/protocol.js +92 -0
  117. package/src/quiet-warnings.js +9 -0
  118. package/src/relay-ai-vm.js +82 -0
  119. package/src/relay-ask.js +141 -0
  120. package/src/relay-ci.js +111 -0
  121. package/src/relay-clash.js +597 -0
  122. package/src/relay-cluster.js +454 -0
  123. package/src/relay-commit.js +887 -0
  124. package/src/relay-filekey.js +46 -0
  125. package/src/relay-git.js +274 -0
  126. package/src/relay-githttp.js +137 -0
  127. package/src/relay-github.js +109 -0
  128. package/src/relay-lease.js +137 -0
  129. package/src/relay-lfs.js +37 -0
  130. package/src/relay-mcp.js +1679 -0
  131. package/src/relay-metrics.js +75 -0
  132. package/src/relay-outbound.js +1144 -0
  133. package/src/relay-sync.js +719 -0
  134. package/src/relay-turn-sync.js +307 -0
  135. package/src/relay-upstream.js +934 -0
  136. package/src/relay-webhooks.js +111 -0
  137. package/src/releases.js +131 -0
  138. package/src/report.js +129 -0
  139. package/src/runner.js +306 -0
  140. package/src/same-edit.js +18 -0
  141. package/src/server.js +4187 -0
  142. package/src/session-access.js +170 -0
  143. package/src/session-name.js +15 -0
  144. package/src/session.js +7361 -0
  145. package/src/settings.js +46 -0
  146. package/src/setup.js +184 -0
  147. package/src/shell-path.js +46 -0
  148. package/src/status.js +199 -0
  149. package/src/summarize.js +109 -0
  150. package/src/sync-words.js +42 -0
  151. package/src/task-comments.js +92 -0
  152. package/src/tasks.js +670 -0
  153. package/src/textdiff.js +20 -0
  154. package/src/turn-sync-plan.js +142 -0
  155. package/src/turn-sync.js +191 -0
  156. package/src/ui/access-form.js +37 -0
  157. package/src/ui/agent-env-key.js +76 -0
  158. package/src/ui/agent-guide.js +127 -0
  159. package/src/ui/agent-kinds.js +175 -0
  160. package/src/ui/agent-place.js +265 -0
  161. package/src/ui/app.css +2309 -0
  162. package/src/ui/app.js +542 -0
  163. package/src/ui/board.js +465 -0
  164. package/src/ui/boxes.js +76 -0
  165. package/src/ui/branches.js +111 -0
  166. package/src/ui/catchup.js +77 -0
  167. package/src/ui/changes.js +159 -0
  168. package/src/ui/chat.js +266 -0
  169. package/src/ui/cloud-ai-model.js +107 -0
  170. package/src/ui/cloud-ai.js +221 -0
  171. package/src/ui/common.js +431 -0
  172. package/src/ui/csv.js +21 -0
  173. package/src/ui/diffview.js +74 -0
  174. package/src/ui/feed-convs.js +39 -0
  175. package/src/ui/feed.js +174 -0
  176. package/src/ui/files.js +579 -0
  177. package/src/ui/fileview.js +123 -0
  178. package/src/ui/git-rule.js +60 -0
  179. package/src/ui/home.js +1323 -0
  180. package/src/ui/index.html +16 -0
  181. package/src/ui/invite.js +97 -0
  182. package/src/ui/invites.js +79 -0
  183. package/src/ui/loom-model.js +516 -0
  184. package/src/ui/loom.css +349 -0
  185. package/src/ui/loom.js +753 -0
  186. package/src/ui/mark.js +104 -0
  187. package/src/ui/merges.js +158 -0
  188. package/src/ui/rail-model.js +78 -0
  189. package/src/ui/rail.js +173 -0
  190. package/src/ui/releases.js +182 -0
  191. package/src/ui/schedule.js +156 -0
  192. package/src/ui/session.js +3394 -0
  193. package/src/ui/signin.js +75 -0
  194. package/src/ui/sounds.js +110 -0
  195. package/src/ui/tool-logo.js +86 -0
  196. package/src/ui/tree.js +276 -0
  197. package/src/ui/workspaces.js +542 -0
  198. package/src/ui-server.js +1272 -0
  199. package/src/update-check.js +67 -0
  200. package/src/upstream.js +155 -0
  201. package/src/webhooks.js +235 -0
  202. package/src/workspace-cli.js +62 -0
  203. package/src/workspace-tools.js +340 -0
  204. package/index.js +0 -2
@@ -0,0 +1,900 @@
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 } 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
+ const read = async (chunk) => {
282
+ const buf = await call(root, ['cat-file', '--batch', '--filters'], { buffer: true, input: chunk.map(([rel, id]) => `${id} ${rel}\n`).join('') })
283
+ if (buf.missing) return false
284
+ if (buf.out === null) { unread(chunk.map(([rel]) => rel)); return true }
285
+ let at = 0
286
+ for (let i = 0; i < chunk.length; i++) {
287
+ const rel = chunk[i][0]
288
+ const nl = buf.out.indexOf(10, at)
289
+ const header = nl < 0 ? '' : buf.out.subarray(at, nl).toString('utf8')
290
+ if (/ missing$/.test(header)) { out.set(rel, null); at = nl + 1; continue }
291
+ const size = Number(header.split(' ')[2])
292
+ if (nl < 0 || !Number.isInteger(size)) { unread(chunk.slice(i).map(([r]) => r)); return true }
293
+ const key = keyOf(buf.out.subarray(nl + 1, nl + 1 + size))
294
+ out.set(rel, key.startsWith(LFS_POINTER) ? undefined : key) // an LFS pointer is not the file
295
+ at = nl + 1 + size + 1
296
+ }
297
+ return true
298
+ }
299
+ let chunk = []; let bytes = 0
300
+ for (const f of fetch) {
301
+ if (chunk.length && bytes + f[2] > MAX_OUTPUT / 4) {
302
+ if (!(await read(chunk))) return null
303
+ chunk = []; bytes = 0
304
+ }
305
+ chunk.push(f); bytes += f[2]
306
+ }
307
+ if (chunk.length && !(await read(chunk))) return null
308
+ return out
309
+ }
310
+
311
+ const LFS_POINTER = 'version https://git-lfs'
312
+
313
+ /** The paths with a `filter` attribute set (in .gitattributes), or null when git failed. */
314
+ async function filterOf (root, rels) {
315
+ const out = await run(root, ['check-attr', '-z', '--stdin', 'filter'], { input: rels.join('\0') + '\0' })
316
+ if (out === null) return null
317
+ const f = out.split('\0')
318
+ const filtered = new Set()
319
+ for (let i = 0; i + 2 < f.length; i += 3) if (f[i + 2] !== 'unspecified' && f[i + 2] !== 'unset') filtered.add(f[i])
320
+ return filtered
321
+ }
322
+
323
+ /** The commit a branch points at (wherever HEAD is), or null. */
324
+ export async function branchTip (root, branch) {
325
+ if (!branch || !gitDir(root)) return null
326
+ return ((await run(root, ['rev-parse', '--verify', '-q', `refs/heads/${branch}^{commit}`])) || '').trim() || null
327
+ }
328
+
329
+ /** Paths that differ between two commits, each with git's status letter (A added, M modified, D deleted...); null when git fails. */
330
+ /** How many commits `to` has that `from` hasn't (what a pull brought in), or null when git can't say. */
331
+ export async function commitsBetween (root, from, to) {
332
+ const out = await run(root, ['rev-list', '--count', `${from}..${to}`])
333
+ const n = out === null ? NaN : Number(String(out).trim())
334
+ return Number.isFinite(n) ? n : null
335
+ }
336
+
337
+ export async function changesBetween (root, shaA, shaB) {
338
+ const out = await run(root, ['diff', '--no-renames', '--no-ext-diff', '--name-status', '-z', shaA, shaB])
339
+ if (out === null) return null
340
+ const f = out.split('\0')
341
+ const changes = new Map()
342
+ for (let i = 0; i + 1 < f.length; i += 2) if (f[i + 1]) changes.set(f[i + 1], f[i][0])
343
+ return changes
344
+ }
345
+
346
+ /** Paths that differ between two commits. */
347
+ export async function changedBetween (root, shaA, shaB) {
348
+ const changes = await changesBetween(root, shaA, shaB)
349
+ return changes ? [...changes.keys()] : []
350
+ }
351
+
352
+ /**
353
+ * What git says about `paths` now, in two git calls whatever their number:
354
+ * `dirty` (changed, staged, untracked or conflicted), `unmerged` (a conflict
355
+ * git left for the person to resolve) and `tracked` (in the index). Null when git fails.
356
+ */
357
+ export async function treeState (root, paths) {
358
+ const spec = paths.length <= MAX_PATHSPECS ? ['--', ...paths] : []
359
+ const status = await run(root, ['--literal-pathspecs', 'status', '--porcelain=v2', '-z', '--untracked-files=all', ...spec])
360
+ if (status === null) return null // not asking for the second when the first failed (or timed out)
361
+ const listed = await run(root, ['--literal-pathspecs', 'ls-files', '-z', ...spec])
362
+ if (listed === null) return null
363
+ const dirty = new Set()
364
+ const unmerged = new Set()
365
+ const f = status.split('\0')
366
+ for (let i = 0; i < f.length; i++) {
367
+ const e = f[i]
368
+ if (e[0] === '1') dirty.add(e.split(' ').slice(8).join(' '))
369
+ else if (e[0] === '2') dirty.add(e.split(' ').slice(9).join(' ')).add(f[++i]) // renamed: both paths
370
+ else if (e[0] === 'u') { const rel = e.split(' ').slice(10).join(' '); dirty.add(rel); unmerged.add(rel) }
371
+ else if (e[0] === '?' || e[0] === '!') dirty.add(e.slice(2))
372
+ }
373
+ return { dirty, unmerged, tracked: new Set(listed.split('\0').filter(Boolean)) }
374
+ }
375
+
376
+ /**
377
+ * Paths with a conflict git left in the index for the person to resolve
378
+ * (stages 1-3), whatever made it: a merge, a rebase, a `stash pop`, `checkout -m`.
379
+ * Null when git fails.
380
+ */
381
+ export async function unmergedPaths (root) {
382
+ const out = await run(root, ['ls-files', '-u', '-z'])
383
+ if (out === null) return null
384
+ const paths = new Set()
385
+ for (const e of out.split('\0')) { const tab = e.indexOf('\t'); if (tab > 0) paths.add(e.slice(tab + 1)) }
386
+ return paths
387
+ }
388
+
389
+ /** A cheap mark that changes whenever something is stashed or a stash is dropped (the stash reflog). */
390
+ export function stashStamp (root) {
391
+ let dir = gitDir(root)
392
+ if (!dir) return null
393
+ try { dir = path.resolve(dir, fs.readFileSync(path.join(dir, 'commondir'), 'utf8').trim()) } catch {} // a worktree keeps its stash with the main repo
394
+ try { const st = fs.statSync(path.join(dir, 'logs', 'refs', 'stash')); return `${st.mtimeMs}:${st.size}` } catch { return null }
395
+ }
396
+
397
+ /**
398
+ * What the last fetch brought that HEAD doesn't have yet: files the upstream
399
+ * branch adds (since HEAD and it parted), as Map path -> the file there as Quilt
400
+ * keys it (text, or "bin:<sha1>"; undefined when it can't be read). Limited
401
+ * to `paths` when given. Empty with no upstream or nothing new; null when git fails.
402
+ */
403
+ export async function upstreamAdds (root, paths = null) {
404
+ if (paths && !paths.length) return new Map()
405
+ const up = await run(root, ['rev-parse', '-q', '--verify', '@{upstream}^{commit}'])
406
+ if (up === null) return new Map() // no upstream (or none fetched): nothing is coming
407
+ const sha = up.trim()
408
+ const spec = paths && paths.length <= MAX_PATHSPECS ? ['--', ...paths] : []
409
+ const out = await run(root, ['--literal-pathspecs', 'diff', '--name-only', '-z', '--no-renames', '--diff-filter=A', `HEAD...${sha}`, ...spec])
410
+ if (out === null) return null
411
+ let added = out.split('\0').filter(Boolean)
412
+ if (paths && !spec.length) { const want = new Set(paths); added = added.filter((rel) => want.has(rel)) }
413
+ if (!added.length) return new Map()
414
+ const at = await filesAt(root, sha, added)
415
+ if (!at) return null
416
+ return new Map(added.map((rel) => [rel, at.get(rel)]))
417
+ }
418
+
419
+ /**
420
+ * Before a pull: the upstream branch, how many commits behind HEAD is, and the
421
+ * files those commits add that sit in this folder untracked (the session put
422
+ * them here). git refuses to pull over those. Null when git fails or there is
423
+ * no upstream.
424
+ */
425
+ export async function pullState (root) {
426
+ const name = await run(root, ['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{upstream}'])
427
+ if (name === null) return null
428
+ const count = await run(root, ['rev-list', '--count', 'HEAD..@{upstream}'])
429
+ if (count === null) return null
430
+ const behind = Number(count.trim()) || 0
431
+ if (!behind) return { upstream: name.trim(), behind, adds: new Map() }
432
+ const adds = await upstreamAdds(root)
433
+ if (!adds) return null
434
+ const paths = [...adds.keys()]
435
+ const listed = paths.length ? await run(root, ['--literal-pathspecs', 'ls-files', '-z', ...(paths.length <= MAX_PATHSPECS ? ['--', ...paths] : [])]) : ''
436
+ if (listed === null) return null
437
+ const tracked = new Set(listed.split('\0').filter(Boolean))
438
+ return { upstream: name.trim(), behind, adds: new Map([...adds].filter(([rel]) => !tracked.has(rel))) }
439
+ }
440
+
441
+ /**
442
+ * Watches HEAD, the index, a fetch (FETCH_HEAD) and the in-progress markers; events: head, index, fetch, busy, idle.
443
+ * Also `ref`: a branch moved without HEAD's file changing (a commit, or another worktree of the
444
+ * same repository moving this branch: `git update-ref`, `git push . x:main`, `git fetch . x:main`).
445
+ */
446
+ export function watchGit (root, onEvent) {
447
+ const dir = gitDir(root)
448
+ if (!dir) return { close: async () => {} }
449
+ const names = new Set(['HEAD', 'index', 'FETCH_HEAD', ...MARKERS.map(([f]) => f)])
450
+ let wasBusy = !!busy(root, dir)
451
+ const watcher = watch(dir, { ignoreInitial: true, depth: 0, followSymlinks: false })
452
+ // Branches live in the shared directory a worktree's .git points into (refs/heads, packed-refs).
453
+ const common = commonDir(root) || dir
454
+ const refs = watch([path.join(common, 'refs', 'heads'), path.join(common, 'packed-refs')], { ignoreInitial: true, followSymlinks: false })
455
+ const onRef = (p) => { if (!p.endsWith('.lock')) onEvent({ type: 'ref' }) }
456
+ refs.on('add', onRef).on('change', onRef).on('unlink', onRef).on('error', () => {})
457
+ const onAny = (p) => {
458
+ const name = path.basename(p)
459
+ if (!names.has(name)) return
460
+ if (name === 'HEAD') onEvent({ type: 'head' })
461
+ else if (name === 'index') onEvent({ type: 'index' })
462
+ else if (name === 'FETCH_HEAD') onEvent({ type: 'fetch' })
463
+ else {
464
+ const now = !!busy(root, dir)
465
+ if (now !== wasBusy) { wasBusy = now; onEvent({ type: now ? 'busy' : 'idle' }) }
466
+ }
467
+ }
468
+ watcher.on('add', onAny).on('change', onAny).on('unlink', onAny).on('addDir', onAny).on('unlinkDir', onAny)
469
+ return { close: () => Promise.all([watcher.close(), refs.close()]) }
470
+ }
471
+
472
+ /** The repository's shared git directory (a worktree's .git/worktrees/<name> points at it), or null. */
473
+ export function commonDir (root) {
474
+ const dir = gitDir(root)
475
+ if (!dir) return null
476
+ try { return path.resolve(dir, fs.readFileSync(path.join(dir, 'commondir'), 'utf8').trim()) } catch { return dir }
477
+ }
478
+
479
+ // ------------------------------------------------------------ upstream --
480
+ // What the folder's branch has to catch up with. A fetch can wait on the network: it gets longer.
481
+ export const FETCH_TIMEOUT_MS = 60 * 1000
482
+
483
+ /**
484
+ * The branch's upstream: { name ("origin/main"), remote ("origin"), url, sha }, or null
485
+ * (no upstream set, or git failed). The address is read from the folder's own git config:
486
+ * nobody sets it in Quilt.
487
+ */
488
+ export async function upstreamOf (root) {
489
+ const name = ((await run(root, ['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{upstream}'])) || '').trim()
490
+ if (!name) return null
491
+ const sha = ((await run(root, ['rev-parse', '-q', '--verify', '@{upstream}^{commit}'])) || '').trim() || null
492
+ const branch = headRef(root)
493
+ const remote = branch ? ((await run(root, ['config', '--get', `branch.${branch}.remote`])) || '').trim() : ''
494
+ // The repository as configured, not as an insteadOf rule rewrites it for transport (a mirror, an SSH rule): that is which repository it is.
495
+ const url = remote && remote !== '.' ? (((await run(root, ['config', '--get', `remote.${remote}.url`])) || '').trim() || ((await run(root, ['remote', 'get-url', remote])) || '').trim()) : ''
496
+ return { name, remote: remote || null, url: url || null, sha }
497
+ }
498
+
499
+ /**
500
+ * Fetches the branch's upstream remote in the background: only remote-tracking refs move, never
501
+ * a file, the index or a branch. No prompt ever waits for a password (GIT_TERMINAL_PROMPT=0, no
502
+ * askpass): a remote that needs one the folder doesn't have just isn't fetched. Returns
503
+ * { ok, error }: `error` says in plain English why it failed (fetchProblem), null when it worked.
504
+ */
505
+ export async function fetchUpstream (root, remote, url = null, { token = null } = {}) {
506
+ if (!remote || remote === '.') return { ok: false, error: null, skipped: true }
507
+ const r = await call(root, ['-c', 'core.askPass=', 'fetch', '--quiet', '--no-tags', '--no-write-fetch-head', '--prune', remote], {
508
+ timeout: FETCH_TIMEOUT_MS,
509
+ 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) }
510
+ })
511
+ if (r.out !== null) return { ok: true, error: null }
512
+ return { ok: false, error: fetchProblem({ stderr: r.stderr, url, remote, timedOut: r.timedOut, missing: r.missing }) }
513
+ }
514
+
515
+ /**
516
+ * Sign-in for one git command over https with a GitHub token (Quilt's app, read only: see
517
+ * relay-github.js), as environment rather than arguments, so it never shows in a process list.
518
+ */
519
+ export function gitAuthEnv (token, { user = 'x-access-token' } = {}) {
520
+ if (!token) return {}
521
+ return { GIT_CONFIG_COUNT: '1', GIT_CONFIG_KEY_0: 'http.extraheader', GIT_CONFIG_VALUE_0: `Authorization: Basic ${Buffer.from(`${user}:${token}`).toString('base64')}` }
522
+ }
523
+
524
+ /**
525
+ * Makes a folder that holds a branch's files (an agent's copy, synced by Quilt) a git checkout of
526
+ * that branch at commit `sha`, with every file left as it is: init, fetch `ref` from `url` (with
527
+ * `token` when the repository is private), the branch at `sha`, the index at `sha`, upstream set.
528
+ * The session's uncommitted work then shows as the working tree's changes. Resolves { ok } or
529
+ * { ok: false, error }; a failure leaves no .git behind.
530
+ */
531
+ export async function backWithGit (root, { url, ref, branch = ref, sha, token = null, user = 'x-access-token', helpers = null }) {
532
+ if (fs.existsSync(path.join(root, '.git'))) return { ok: true, had: true }
533
+ const env = { GIT_TERMINAL_PROMPT: '0', GIT_ASKPASS: '', SSH_ASKPASS: '', GCM_INTERACTIVE: 'never', ...gitAuthEnv(token, { user }) }
534
+ const undo = () => { try { fs.rmSync(path.join(root, '.git'), { recursive: true, force: true }) } catch {} }
535
+ const step = async (args, what, opts = {}) => {
536
+ const r = await call(root, args, { timeout: FETCH_TIMEOUT_MS * 10, env, ...opts })
537
+ 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)}`) }
538
+ return r.out
539
+ }
540
+ try {
541
+ await step(['init', '-q', '-b', branch], 'git init')
542
+ await step(['remote', 'add', 'origin', url], 'adding the remote')
543
+ await step(['config', 'remote.origin.fetch', `+refs/heads/*:refs/remotes/origin/*`], 'remote config')
544
+ await step(['-c', 'core.askPass=', 'fetch', '--quiet', '--no-tags', 'origin', ref], `fetching ${ref}`)
545
+ // The commit the session's files are at may be behind the branch's tip (or on it): make sure it's here.
546
+ 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)}`)
547
+ await step(['update-ref', `refs/heads/${branch}`, sha], 'setting the branch')
548
+ await step(['symbolic-ref', 'HEAD', `refs/heads/${branch}`], 'checking out the branch')
549
+ await step(['read-tree', '--reset', sha], 'setting the index')
550
+ await step(['branch', `--set-upstream-to=origin/${ref}`, branch], 'tracking the upstream')
551
+ const exclude = path.join(root, '.git', 'info', 'exclude')
552
+ fs.mkdirSync(path.dirname(exclude), { recursive: true })
553
+ fs.appendFileSync(exclude, '.quilt/\n.agent-scratch/\n')
554
+ if (helpers) { try { await installGitHelpers(root, { ...helpers, host: remoteHost(url), origin: urlOrigin(url) }) } catch {} }
555
+ return { ok: true }
556
+ } catch (err) { undo(); return { ok: false, error: err.message } }
557
+ }
558
+
559
+ /** A string as one word for sh (the credential helper and the hook run through it). */
560
+ export const shellQuote = (s) => `'${String(s).replace(/'/g, "'\\''")}'`
561
+
562
+ const HOOK_MARK = '# Quilt pre-push hook'
563
+
564
+ /**
565
+ * Lets an agent's folder use git like anyone: git asks Quilt for credentials for `host`
566
+ * (`quilt git-credential`, which asks the running session, which asks the relay for a short-lived
567
+ * token for this repository, write access within the owner's policy), a pre-push hook
568
+ * (`quilt git-pre-push`) holds pushes to that policy and tells the session what was pushed, and
569
+ * commits are credited to the agent (`identity`: { name, email }, unless the folder has its own).
570
+ * `node` and `script` run the CLI. Idempotent; a hook that is someone else's is left alone, and a
571
+ * folder with core.hooksPath gets no hook. Returns { helper, hook } (what is in place).
572
+ */
573
+ export async function installGitHelpers (root, { node, script, host = 'github.com', origin = '', identity = null }) {
574
+ const dir = gitDir(root)
575
+ if (!dir || !node || !script) return { helper: false, hook: false }
576
+ const cmd = (sub) => `!${shellQuote(node)} ${shellQuote(script)} ${sub} --dir ${shellQuote(root)}`
577
+ let helper = false
578
+ if (host) {
579
+ // `origin`: the remote's scheme, host and port (the relay's git, http:// in tests); else https://<host>.
580
+ const key = `credential.${origin || `https://${host}`}.helper`
581
+ const have = String((await call(root, ['config', '--local', '--get-all', key])).out || '').split('\n')
582
+ const want = cmd('git-credential')
583
+ // Exactly: an empty helper first (whatever is configured globally for this host, a keychain with a
584
+ // 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.
585
+ if (have.length !== 2 || have[0] !== '' || have[1] !== want) {
586
+ await call(root, ['config', '--local', '--replace-all', key, ''])
587
+ await call(root, ['config', '--local', '--add', key, want])
588
+ }
589
+ helper = true
590
+ }
591
+ if (identity && identity.name && identity.email) {
592
+ if (!(await call(root, ['config', '--local', 'user.name'])).out) await call(root, ['config', '--local', 'user.name', identity.name])
593
+ if (!(await call(root, ['config', '--local', 'user.email'])).out) await call(root, ['config', '--local', 'user.email', identity.email])
594
+ }
595
+ const hook = await writeQuiltHook(root, { node, script })
596
+ return { helper, hook }
597
+ }
598
+
599
+ /**
600
+ * Quilt's pre-push hook in the folder's own hooks (a hook that is someone else's is left alone, and a
601
+ * folder with core.hooksPath gets none). Returns whether it is in place.
602
+ */
603
+ async function writeQuiltHook (root, { node, script }) {
604
+ const dir = gitDir(root)
605
+ if (!dir) return false
606
+ const hooksPath = (await call(root, ['config', 'core.hooksPath'])).out
607
+ if (hooksPath) return false
608
+ const file = path.join(dir, 'hooks', 'pre-push')
609
+ let now = ''
610
+ try { now = fs.readFileSync(file, 'utf8') } catch {}
611
+ if (now && !now.includes(HOOK_MARK)) return false
612
+ 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.\nexec ${shellQuote(node)} ${shellQuote(script)} git-pre-push --dir ${shellQuote(root)} "$@"\n`
613
+ if (now !== want) {
614
+ fs.mkdirSync(path.dirname(file), { recursive: true })
615
+ fs.writeFileSync(file, want, { mode: 0o755 })
616
+ try { fs.chmodSync(file, 0o755) } catch {}
617
+ }
618
+ return true
619
+ }
620
+
621
+ /** The credential sections in the folder's own config whose helper is Quilt's for this folder: [credential.<url>.helper keys]. */
622
+ async function quiltHelperKeys (root) {
623
+ const out = (await call(root, ['config', '--local', '--get-regexp', '^credential\\..*\\.helper$'])).out || ''
624
+ const keys = new Set()
625
+ for (const line of out.split('\n')) {
626
+ const i = line.indexOf(' ')
627
+ if (i < 0) continue
628
+ if (/ git-credential --dir /.test(line.slice(i + 1))) keys.add(line.slice(0, i))
629
+ }
630
+ return [...keys]
631
+ }
632
+
633
+ /**
634
+ * Git for everyone, in a person's own folder (docs/superpowers/specs/2026-10-17-git-for-everyone.md):
635
+ * Quilt's credential helper for the session's repository only (`url`, as git asks for it: the remote's
636
+ * URL with insteadOf applied), in the folder's own config: an empty helper first, so the person's other
637
+ * helpers (a keychain) are set aside for that URL, then `quilt git-credential --fallback`, which asks
638
+ * them anyway when no Quilt session answers. `helper: false` takes Quilt's helper away (any URL);
639
+ * `hook` puts the pre-push hook in, or takes it away. Global config and keychains are never touched.
640
+ * Returns { helper, hook } (what is in place now).
641
+ */
642
+ export async function setPersonGit (root, { node, script, url = '', helper = false, hook = false }) {
643
+ if (!gitDir(root) || !node || !script) return { helper: false, hook: false }
644
+ const key = helper && url ? `credential.${url}.helper` : null
645
+ // Quilt's helper under any other URL (the remote moved, or git turned off) goes.
646
+ for (const k of await quiltHelperKeys(root)) if (k !== key) await call(root, ['config', '--local', '--unset-all', k])
647
+ let helperOn = false
648
+ if (key) {
649
+ const want = `!${shellQuote(node)} ${shellQuote(script)} git-credential --dir ${shellQuote(root)} --fallback`
650
+ const have = String((await call(root, ['config', '--local', '--get-all', key])).out || '').split('\n')
651
+ if (have.length !== 2 || have[0] !== '' || have[1] !== want) {
652
+ await call(root, ['config', '--local', '--replace-all', key, ''])
653
+ await call(root, ['config', '--local', '--add', key, want])
654
+ }
655
+ helperOn = true
656
+ }
657
+ let hookOn = false
658
+ if (hook) hookOn = await writeQuiltHook(root, { node, script })
659
+ else removeQuiltHook(root)
660
+ return { helper: helperOn, hook: hookOn }
661
+ }
662
+
663
+ /** Takes Quilt's pre-push hook out of the folder (only Quilt's: a hook of the person's own stays). */
664
+ export function removeQuiltHook (root) {
665
+ const dir = gitDir(root)
666
+ if (!dir) return
667
+ const file = path.join(dir, 'hooks', 'pre-push')
668
+ try { if (fs.readFileSync(file, 'utf8').includes(HOOK_MARK)) fs.rmSync(file, { force: true }) } catch {}
669
+ }
670
+
671
+ /** scheme://host[:port] of an http(s) remote URL, or '' (a path, ssh). */
672
+ export function urlOrigin (url) {
673
+ try { const u = new URL(String(url || '')); return /^https?:$/.test(u.protocol) ? u.origin : '' } catch { return '' }
674
+ }
675
+
676
+ /** The host a remote URL points at ("github.com"), or null for a local path. */
677
+ export function remoteHost (url) {
678
+ if (typeof url !== 'string' || !url) return null
679
+ const scp = /^[A-Za-z0-9._-]+@([A-Za-z0-9.-]+):(?!\/)/.exec(url)
680
+ if (scp) return scp[1].toLowerCase()
681
+ try { const u = new URL(url); return u.protocol === 'file:' ? null : (u.hostname || null) } catch { return null }
682
+ }
683
+
684
+ /**
685
+ * Why `git fetch` failed, in plain English, from what git printed: "git can't sign in to
686
+ * github.com from this folder", "can't reach github.com (no network?)", and so on. Never the
687
+ * URL itself (it may carry credentials).
688
+ */
689
+ export function fetchProblem ({ stderr = '', url = null, remote = 'origin', timedOut = false, missing = false } = {}) {
690
+ const host = remoteHost(url) || remote || 'the remote'
691
+ const e = String(stderr || '').toLowerCase()
692
+ if (missing) return "git can't be run here"
693
+ if (timedOut) return `fetching from ${host} took over a minute and was stopped`
694
+ 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`
695
+ 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)) {
696
+ return `git can't sign in to ${host} from this folder`
697
+ }
698
+ 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)) {
699
+ return `can't reach ${host} (no network?)`
700
+ }
701
+ const line = String(stderr || '').split('\n').map((l) => l.replace(/^(fatal|error):\s*/i, '').trim()).find(Boolean)
702
+ return line ? `git fetch failed: ${line.replace(/[a-z]+:\/\/[^\s'"]*@/gi, '').slice(0, 120)}` : 'git fetch failed'
703
+ }
704
+
705
+ /** Whether the repository has commit `sha` (fetched or made here). */
706
+ export async function hasCommit (root, sha) {
707
+ if (!sha) return false
708
+ return (await call(root, ['cat-file', '-e', `${sha}^{commit}`])).out !== null
709
+ }
710
+
711
+ /** Whether commit a is an ancestor of (or the same as) b; null when git can't say. */
712
+ export async function isAncestor (root, a, b) {
713
+ const r = await call(root, ['merge-base', '--is-ancestor', a, b])
714
+ if (r.out !== null) return true
715
+ if (r.missing || r.timedOut) return null
716
+ // Exit 1 is "no"; anything else (an unknown commit) is "can't say".
717
+ const known = await run(root, ['cat-file', '-e', `${a}^{commit}`]) !== null && await run(root, ['cat-file', '-e', `${b}^{commit}`]) !== null
718
+ return known ? false : null
719
+ }
720
+
721
+ /** Whether the index has changes staged against `sha`; null when git can't say. */
722
+ export async function stagedAgainst (root, sha) {
723
+ const r = await call(root, ['diff-index', '--cached', '--quiet', '--ignore-submodules', sha, '--'])
724
+ if (r.out !== null) return false
725
+ return r.missing || r.timedOut ? null : true
726
+ }
727
+
728
+ /**
729
+ * Moves the folder's branch from `from` to `to` (`branch` null: it is there already) and sets the
730
+ * index to `to`, leaving every file as it is; `during()` (writing `to`'s files: see catchUp) runs first.
731
+ * git's index lock is held throughout, so a person's `git commit` meanwhile either went first (the
732
+ * branch is no longer at `from`: not moved) or is refused by git and asked again. The index is set
733
+ * before the branch moves: git reads HEAD, then the index, and only then takes the lock, so a commit
734
+ * that read the new HEAD reads the new index too, and one that read the old HEAD fails git's own check
735
+ * that HEAD hasn't moved. Never a commit on `to` made from the old index or missing `to`'s files,
736
+ * which would undo `to`'s changes on GitHub. Returns whether it moved; `fastForward.indexLate` is set
737
+ * when the index could not follow yet.
738
+ */
739
+ export async function fastForward (root, branch, from, to, during = null) {
740
+ const lock = await lockIndex(root)
741
+ if (!lock) return false
742
+ try {
743
+ if (branch && ((await run(root, ['rev-parse', '-q', '--verify', `refs/heads/${branch}`])) || '').trim() !== from) return false
744
+ if (during) await during()
745
+ const r = await moveUnderLock(root, lock, branch, from, to, 'quilt: catch up with upstream')
746
+ fastForward.indexLate = r.indexLate
747
+ return r.moved
748
+ } finally { lock.release() }
749
+ }
750
+
751
+ /**
752
+ * Under the index lock: the index becomes `to`'s, then `branch` moves from `from` to `to` (see
753
+ * fastForward for why in that order). When the branch moved meanwhile, the index goes back as it was.
754
+ * When git can't set the index, the branch moves all the same and { indexLate } says so (fixIndex sets it).
755
+ */
756
+ async function moveUnderLock (root, lock, branch, from, to, reason) {
757
+ const kept = branch ? lock.keep() : false
758
+ const indexSet = await lock.setIndex(to)
759
+ if (branch && (await call(root, ['update-ref', '-m', reason, `refs/heads/${branch}`, to, from])).out === null) {
760
+ if (indexSet && kept) lock.putBack()
761
+ return { moved: false, indexLate: false }
762
+ }
763
+ return { moved: true, indexLate: !indexSet }
764
+ }
765
+
766
+ /**
767
+ * Takes git's index lock (index.lock) as git itself does, asking again for up to two seconds while
768
+ * another git holds it; null when it can't. While it is held, `setIndex(sha)` makes the index
769
+ * `sha`'s tree (keeping what git knows of files that didn't change) and `release()` lets git in again.
770
+ */
771
+ export async function lockIndex (root) {
772
+ const dir = gitDir(root)
773
+ if (!dir) return null
774
+ const index = path.join(dir, 'index')
775
+ const lock = `${index}.lock`
776
+ const prev = `${index}.quilt-prev-${process.pid}`
777
+ for (let i = 0; i < 20; i++) {
778
+ try {
779
+ fs.closeSync(fs.openSync(lock, 'wx'))
780
+ ownLocks.add(lock)
781
+ return {
782
+ async setIndex (sha) {
783
+ const next = `${index}.quilt-${process.pid}`
784
+ try { fs.copyFileSync(index, next) } catch {}
785
+ const r = await call(root, ['read-tree', '--reset', sha], { env: { GIT_INDEX_FILE: next } })
786
+ try {
787
+ if (r.out === null) return false
788
+ fs.renameSync(next, index)
789
+ return true
790
+ } catch { return false } finally { fs.rmSync(next, { force: true }); fs.rmSync(`${next}.lock`, { force: true }) }
791
+ },
792
+ // A copy of the index as it is, put back by putBack() (the branch it was for didn't move after all).
793
+ keep () { try { fs.copyFileSync(index, prev); return true } catch { return false } },
794
+ putBack () { try { fs.renameSync(prev, index) } catch {} },
795
+ release () { fs.rmSync(prev, { force: true }); fs.rmSync(lock, { force: true }); ownLocks.delete(lock) }
796
+ }
797
+ } catch (err) { if (err.code !== 'EEXIST') return null }
798
+ await new Promise((resolve) => setTimeout(resolve, 100))
799
+ }
800
+ return null
801
+ }
802
+
803
+ /**
804
+ * Every branch and worktree of the repository, as seen from this folder:
805
+ * { worktrees: [{ path, branch, sha }], branches: [{ name, sha, upstream, ahead, behind, author, ts, subject }] }.
806
+ * Null when git fails. Worktrees share the repository, so one folder sees them all.
807
+ */
808
+ export async function repoBranches (root) {
809
+ const wt = await run(root, ['worktree', 'list', '--porcelain'])
810
+ if (wt === null) return null
811
+ const worktrees = []
812
+ let cur = null
813
+ for (const line of wt.split('\n')) {
814
+ 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\//, '') }
815
+ }
816
+ const fmt = '%(refname:short)%00%(objectname)%00%(upstream:short)%00%(upstream:track,nobracket)%00%(authorname)%00%(committerdate:unix)%00%(contents:subject)'
817
+ const refs = await run(root, ['for-each-ref', '--sort=-committerdate', '--count=200', `--format=${fmt}`, 'refs/heads'])
818
+ if (refs === null) return null
819
+ const branches = []
820
+ for (const line of refs.split('\n')) {
821
+ if (!line) continue
822
+ const [name, sha, upstream, track, author, ts, subject] = line.split('\0')
823
+ const n = (word) => { const m = new RegExp(`${word} (\\d+)`).exec(track || ''); return m ? Number(m[1]) : 0 }
824
+ branches.push({ name, sha, upstream: upstream || null, ahead: n('ahead'), behind: n('behind'), gone: /gone/.test(track || ''), author, ts: Number(ts) * 1000 || 0, subject })
825
+ }
826
+ return { worktrees, branches }
827
+ }
828
+
829
+ /** A file's bytes at a commit as checkout would write them (--filters), or null. */
830
+ export async function blobAt (root, sha, rel) {
831
+ const out = await run(root, ['cat-file', '--filters', `${sha}:${rel}`], { buffer: true })
832
+ return out === null ? null : out
833
+ }
834
+
835
+ /** Whether the commit has any file under `dir` ('' is the top), or null when git fails. */
836
+ export async function hasFilesUnder (root, sha, dir) {
837
+ const out = await run(root, ['--literal-pathspecs', 'ls-tree', '-r', '--name-only', sha, '--', dir || '.'])
838
+ return out === null ? null : out.trim().length > 0
839
+ }
840
+
841
+ /**
842
+ * Sets the index to `sha` (what HEAD now points at), leaving every file as it is. Another git
843
+ * (an AI's `git status`) may hold the index lock a moment: asked again for up to two seconds.
844
+ */
845
+ export async function resetIndex (root, sha) {
846
+ for (let i = 0; i < 20; i++) {
847
+ const r = await call(root, ['read-tree', '--reset', sha])
848
+ if (r.out !== null) return true
849
+ if (r.missing || r.timedOut) return false
850
+ await new Promise((resolve) => setTimeout(resolve, 100))
851
+ }
852
+ return false
853
+ }
854
+
855
+ /**
856
+ * Pushes `sha` to `remote`'s `branch` with the folder's own git and credentials: forward only (never
857
+ * forced), with no prompt waiting for a password. { ok, error }.
858
+ */
859
+ export async function pushLocal (root, remote, sha, branch) {
860
+ const r = await call(root, ['-c', 'core.askPass=', 'push', '--quiet', '--no-verify', remote, `${sha}:refs/heads/${branch}`], {
861
+ timeout: FETCH_TIMEOUT_MS,
862
+ env: { GIT_TERMINAL_PROMPT: '0', GIT_ASKPASS: '', SSH_ASKPASS: '', GCM_INTERACTIVE: 'never', GIT_SSH_COMMAND: process.env.GIT_SSH_COMMAND || 'ssh -o BatchMode=yes' }
863
+ })
864
+ return r.out !== null ? { ok: true, error: null } : { ok: false, error: (r.stderr || '').trim().split('\n').filter(Boolean).pop() || 'git push failed' }
865
+ }
866
+
867
+ /** The commits on `branch` that `upstream` hasn't, as a git bundle (a Buffer), or null. */
868
+ export async function bundleOf (root, branch, upstream) {
869
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'quilt-bundle-'))
870
+ try {
871
+ const file = path.join(dir, 'local.bundle')
872
+ const r = await call(root, ['bundle', 'create', '--quiet', file, `refs/heads/${branch}`, ...(upstream ? [`^${upstream}`] : [])], { timeout: FETCH_TIMEOUT_MS })
873
+ if (r.out === null) return null
874
+ return fs.readFileSync(file)
875
+ } catch { return null } finally { fs.rmSync(dir, { recursive: true, force: true }) }
876
+ }
877
+
878
+ /**
879
+ * The folder's branch moves to `to` (GitHub's head, which now carries this folder's local commits,
880
+ * published or replayed by the relay) from `from`, its local commits kept under
881
+ * refs/quilt/original/<branch>/<time> first. Only the branch and the index move, never a file; and
882
+ * only when it is safe: HEAD still on the branch at `from`, nothing staged, no git operation under
883
+ * way. Returns { moved, why }.
884
+ */
885
+ export async function adoptPublished (root, branch, from, to) {
886
+ if (busy(root)) return { moved: false, why: 'a git operation is under way here' }
887
+ const head = ((await run(root, ['rev-parse', '-q', '--verify', 'HEAD'])) || '').trim()
888
+ if (head !== from || headRef(root) !== branch) return { moved: false, why: 'the branch moved meanwhile' }
889
+ const staged = await stagedAgainst(root, from)
890
+ if (staged !== false) return { moved: false, why: 'changes are staged for a commit here' }
891
+ const keep = await call(root, ['update-ref', '-m', 'quilt: local commits, published', `refs/quilt/original/${branch}/${Date.now()}`, from])
892
+ if (keep.out === null) return { moved: false, why: 'git could not keep the local commits' }
893
+ // Under the index lock, the index first, as fastForward: no commit made on `to` from the old index.
894
+ const lock = await lockIndex(root)
895
+ if (!lock) return { moved: false, why: 'another git holds the index' }
896
+ try {
897
+ const r = await moveUnderLock(root, lock, branch, from, to, 'quilt: follow the published commits')
898
+ return r.moved ? { moved: true, why: null } : { moved: false, why: 'the branch moved meanwhile' }
899
+ } finally { lock.release() }
900
+ }