@dirwin517/bot-farm 0.1.0

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 (55) hide show
  1. package/.idea/bot-farm.iml +9 -0
  2. package/.idea/misc.xml +6 -0
  3. package/.idea/modules.xml +8 -0
  4. package/.idea/vcs.xml +6 -0
  5. package/LICENSE +21 -0
  6. package/README.md +584 -0
  7. package/package.json +13 -0
  8. package/src/api.mjs +567 -0
  9. package/src/cli.mjs +241 -0
  10. package/src/craft.mjs +172 -0
  11. package/src/db.mjs +93 -0
  12. package/src/farmhands.mjs +111 -0
  13. package/src/git.mjs +578 -0
  14. package/src/identity.mjs +199 -0
  15. package/src/legacy-defaults.mjs +44 -0
  16. package/src/mcp.mjs +681 -0
  17. package/src/mesh.mjs +222 -0
  18. package/src/packets.mjs +125 -0
  19. package/src/patches.mjs +141 -0
  20. package/src/pipelines.mjs +2145 -0
  21. package/src/pricing.mjs +91 -0
  22. package/src/projects.mjs +148 -0
  23. package/src/public/classic.html +1810 -0
  24. package/src/public/index.html +3284 -0
  25. package/src/questions.mjs +69 -0
  26. package/src/quota.mjs +76 -0
  27. package/src/registry.mjs +88 -0
  28. package/src/replay.mjs +151 -0
  29. package/src/rooms.mjs +320 -0
  30. package/src/server.mjs +765 -0
  31. package/src/store.mjs +546 -0
  32. package/src/supervisor.mjs +1803 -0
  33. package/src/tasks.mjs +191 -0
  34. package/src/template.mjs +86 -0
  35. package/src/tools.mjs +241 -0
  36. package/src/workspaces.mjs +605 -0
  37. package/src/ws.mjs +130 -0
  38. package/src/yaml.mjs +266 -0
  39. package/test/board.mjs +108 -0
  40. package/test/craft.mjs +281 -0
  41. package/test/extend.mjs +145 -0
  42. package/test/flow.mjs +165 -0
  43. package/test/handoff.mjs +228 -0
  44. package/test/linked.mjs +83 -0
  45. package/test/loop.mjs +168 -0
  46. package/test/mock-opencode.mjs +217 -0
  47. package/test/parallel.mjs +135 -0
  48. package/test/patches.mjs +93 -0
  49. package/test/repos.mjs +118 -0
  50. package/test/roles.mjs +157 -0
  51. package/test/shot.mjs +65 -0
  52. package/test/smoke.mjs +70 -0
  53. package/test/transport.mjs +131 -0
  54. package/test/workspaces.mjs +228 -0
  55. package/test/workspaces.mjs.tmp +0 -0
package/src/git.mjs ADDED
@@ -0,0 +1,578 @@
1
+ // Git worktree plumbing. Each botfarm "pod" can own an isolated worktree so
2
+ // parallel sessions never fight over the same checkout.
3
+
4
+ import { execFile } from "node:child_process"
5
+ import { promisify } from "node:util"
6
+ import { basename, join, resolve, relative, dirname } from "node:path"
7
+ import { homedir } from "node:os"
8
+ import { stat, lstat, readdir, cp, realpath, symlink, unlink, mkdir as mkdirp, readFile as readF, writeFile as writeF } from "node:fs/promises"
9
+
10
+ const run = promisify(execFile)
11
+
12
+ /**
13
+ * Paths arrive from text boxes and YAML, where "~/workspace/thing" is what a
14
+ * person types. Node does not expand it — the shell does — so without this
15
+ * every path with a tilde becomes a nonexistent relative directory and git
16
+ * fails with something that looks nothing like the real problem.
17
+ */
18
+ export function expandPath(input) {
19
+ if (!input) return input
20
+ let p = String(input).trim()
21
+ if (p === "~") p = homedir()
22
+ else if (p.startsWith("~/") || p.startsWith("~\\")) p = join(homedir(), p.slice(2))
23
+ p = p.replace(/\$(\w+)|\$\{(\w+)\}/g, (m, a, b) => process.env[a ?? b] ?? m)
24
+ return resolve(p)
25
+ }
26
+
27
+ /**
28
+ * What is actually at this path — so the dashboard can say "no such directory"
29
+ * or "not a git repository" rather than conflating the two.
30
+ */
31
+ export async function inspect(input) {
32
+ const path = expandPath(input)
33
+ let info
34
+ try {
35
+ info = await stat(path)
36
+ } catch {
37
+ return { path, exists: false, isRepo: false, reason: "no such directory" }
38
+ }
39
+ if (!info.isDirectory()) return { path, exists: true, isRepo: false, reason: "not a directory" }
40
+ const root = await repoRoot(path)
41
+ if (!root) return { path, exists: true, isRepo: false, reason: "not a git repository — run git init, or point at the repo above it" }
42
+ const nested = await nestedRepos(root)
43
+ return { path, root, exists: true, isRepo: true, nested, branch: (await status(root))?.branch ?? null }
44
+ }
45
+
46
+ /**
47
+ * Repositories checked out *inside* another repository — the common
48
+ * "parent project with the services cloned into a gitignored folder" layout.
49
+ * Found on the filesystem rather than via git, precisely because the parent
50
+ * ignores them and so git will not mention them.
51
+ */
52
+ export async function nestedRepos(root, { maxDepth = 3 } = {}) {
53
+ const out = []
54
+ const skip = new Set(["node_modules", ".git", "target", "build", "dist", "vendor", ".venv", "__pycache__"])
55
+ async function walk(dir, depth) {
56
+ if (depth > maxDepth) return
57
+ let entries = []
58
+ try {
59
+ entries = await readdir(dir, { withFileTypes: true })
60
+ } catch {
61
+ return
62
+ }
63
+ for (const e of entries) {
64
+ if (!e.isDirectory() || skip.has(e.name) || e.name.startsWith(".")) continue
65
+ const full = join(dir, e.name)
66
+ let isRepo = false
67
+ try {
68
+ await stat(join(full, ".git"))
69
+ isRepo = true
70
+ } catch {}
71
+ if (isRepo) {
72
+ out.push({ path: full, rel: relative(root, full), name: e.name, branch: (await status(full))?.branch ?? null })
73
+ continue // a repo inside a repo inside a repo is somebody else's problem
74
+ }
75
+ await walk(full, depth + 1)
76
+ }
77
+ }
78
+ await walk(root, 1)
79
+ return out.sort((a, b) => a.rel.localeCompare(b.rel))
80
+ }
81
+
82
+ async function git(cwd, args) {
83
+ const { stdout } = await run("git", args, { cwd, maxBuffer: 8 * 1024 * 1024 })
84
+ return stdout
85
+ }
86
+
87
+ /**
88
+ * In a worktree, `.git` is a file pointing elsewhere, so <dir>/.git/info/exclude
89
+ * does not exist. Ask git where its directory actually is.
90
+ */
91
+ export async function gitDir(dir) {
92
+ try {
93
+ // The *common* dir, not the per-worktree one: git reads info/exclude from
94
+ // the shared directory, so writing to the worktree's own has no effect.
95
+ return (await git(dir, ["rev-parse", "--path-format=absolute", "--git-common-dir"])).trim()
96
+ } catch {
97
+ return null
98
+ }
99
+ }
100
+
101
+ export async function excludeLocally(dir, pattern) {
102
+ const gd = await gitDir(dir)
103
+ if (!gd) return false
104
+ const { mkdir, readFile, appendFile } = await import("node:fs/promises")
105
+ const file = join(gd, "info", "exclude")
106
+ await mkdir(join(gd, "info"), { recursive: true })
107
+ const current = await readFile(file, "utf8").catch(() => "")
108
+ if (current.split("\n").includes(pattern)) return true
109
+ await appendFile(file, (current.endsWith("\n") || !current ? "" : "\n") + pattern + "\n")
110
+ return true
111
+ }
112
+
113
+ export async function repoRoot(dir) {
114
+ try {
115
+ return (await git(expandPath(dir), ["rev-parse", "--show-toplevel"])).trim()
116
+ } catch {
117
+ return null
118
+ }
119
+ }
120
+
121
+ export async function commonDir(dir) {
122
+ // For a worktree this resolves to the main repo's .git dir, which is how we
123
+ // group worktrees back to their parent repository.
124
+ try {
125
+ const out = (await git(dir, ["rev-parse", "--path-format=absolute", "--git-common-dir"])).trim()
126
+ return out.replace(/\/\.git$/, "")
127
+ } catch {
128
+ return null
129
+ }
130
+ }
131
+
132
+ export async function listWorktrees(repo) {
133
+ const out = await git(repo, ["worktree", "list", "--porcelain"])
134
+ const trees = []
135
+ let cur = null
136
+ for (const line of out.split("\n")) {
137
+ if (line.startsWith("worktree ")) {
138
+ cur = { path: line.slice(9).trim(), branch: null, detached: false, locked: false }
139
+ trees.push(cur)
140
+ } else if (line.startsWith("branch ") && cur) {
141
+ cur.branch = line.slice(7).trim().replace("refs/heads/", "")
142
+ } else if (line === "detached" && cur) {
143
+ cur.detached = true
144
+ } else if (line.startsWith("locked") && cur) {
145
+ cur.locked = true
146
+ }
147
+ }
148
+ return trees
149
+ }
150
+
151
+ export function defaultWorktreePath(repo, branch) {
152
+ const root = process.env.BOT_FARM_WORKTREE_ROOT || join(homedir(), "worktrees")
153
+ return join(root, basename(repo), branch.replace(/[^\w.-]+/g, "-"))
154
+ }
155
+
156
+ export async function addWorktree(repo, branch, { path, base } = {}) {
157
+ const target = expandPath(path ?? defaultWorktreePath(repo, branch))
158
+ const existing = await listWorktrees(repo)
159
+ const hit = existing.find((w) => w.path === target || w.branch === branch)
160
+ if (hit) return { path: hit.path, branch: hit.branch ?? branch, reused: true }
161
+
162
+ const branches = (await git(repo, ["branch", "--list", branch])).trim()
163
+ const args = branches
164
+ ? ["worktree", "add", target, branch]
165
+ : ["worktree", "add", "-b", branch, target, base ?? (await defaultBase(repo))]
166
+ await git(repo, args)
167
+ return { path: target, branch, reused: false }
168
+ }
169
+
170
+ export async function removeWorktree(repo, path, { force = false } = {}) {
171
+ await git(repo, ["worktree", "remove", ...(force ? ["--force"] : []), path])
172
+ }
173
+
174
+ /** Delete a local branch (never a remote one). */
175
+ export async function deleteBranch(repo, branch) {
176
+ await git(expandPath(repo), ["branch", "-D", branch])
177
+ }
178
+
179
+ /**
180
+ * Commits on `branch` that no other branch or remote contains: the work that
181
+ * would be lost for good if the branch went away. null if there is no branch.
182
+ */
183
+ export async function unmergedCommits(repo, branch) {
184
+ repo = expandPath(repo)
185
+ try {
186
+ await git(repo, ["rev-parse", "--verify", `refs/heads/${branch}`])
187
+ } catch {
188
+ return null
189
+ }
190
+ const out = await git(repo, ["rev-list", "--count", branch, "--not", `--exclude=refs/heads/${branch}`, "--branches", "--remotes"]).catch(() => "0")
191
+ return Number(out.trim()) || 0
192
+ }
193
+
194
+ async function defaultBase(repo) {
195
+ for (const candidate of ["HEAD"]) {
196
+ try {
197
+ await git(repo, ["rev-parse", "--verify", candidate])
198
+ return candidate
199
+ } catch {}
200
+ }
201
+ return "HEAD"
202
+ }
203
+
204
+ /**
205
+ * One worktree per repository, laid out exactly like the original tree.
206
+ *
207
+ * For a parent project whose services live in a gitignored folder, this is the
208
+ * layout that actually works: a worktree of the parent, and a worktree of each
209
+ * selected service placed at the same relative path inside it. Every repo ends
210
+ * up on its own branch and the agent sees the directory structure it expects.
211
+ */
212
+ export async function addWorktreeSet(repo, branch, { path, include = [], base } = {}) {
213
+ const root = await repoRoot(repo)
214
+ if (!root) throw new Error(`${expandPath(repo)} is not a git repository`)
215
+ const parent = await addWorktree(root, branch, { path, base })
216
+ const children = []
217
+ for (const svc of serviceSources(root, include)) children.push(await addServiceWorktree(parent.path, svc, branch, { base }))
218
+ return { ...parent, children }
219
+ }
220
+
221
+ const MOUNT_RE = /^[A-Za-z0-9][A-Za-z0-9_.-]{0,62}$/
222
+
223
+ /**
224
+ * A workspace's extra repositories, as { source, rel, external }. An entry is
225
+ * a path relative to the workspace (a repo nested inside it), or an absolute
226
+ * / ~ path to a repo anywhere else — mounted in the worktree under its folder
227
+ * name, or under `as` with { path, as }.
228
+ */
229
+ export function serviceSources(root, services = []) {
230
+ const out = []
231
+ for (const e of services ?? []) {
232
+ const o = typeof e === "string" ? { path: e } : e ?? {}
233
+ const p = String(o.path ?? "").trim()
234
+ if (!p) continue
235
+ const abs = p.startsWith("/") || p.startsWith("~")
236
+ const source = abs ? expandPath(p) : join(root, p)
237
+ const inside = root && (source === root || source.startsWith(root + "/"))
238
+ if (inside && source === root) continue
239
+ const rel = inside ? relative(root, source) : String(o.as || basename(source))
240
+ out.push({ source, rel, external: !inside, entry: e })
241
+ }
242
+ return out
243
+ }
244
+
245
+ /** Check an extra repo before it is added: a repo, and a mount name that is free. */
246
+ export async function checkService(root, entry, { taken = [] } = {}) {
247
+ const [svc] = serviceSources(root, [entry])
248
+ if (!svc) throw new Error("give a folder: a path inside the workspace, or an absolute or ~ path to a repo")
249
+ const top = await repoRoot(svc.source)
250
+ if (!top) throw new Error(`${svc.source} is not a git repository`)
251
+ if (svc.external && !MOUNT_RE.test(svc.rel)) throw new Error(`"${svc.rel}" cannot be a folder name in the worktree — pick another with "as"`)
252
+ if (taken.includes(svc.rel)) throw new Error(`the worktree already has something at ${svc.rel}/ — pick another name with "as"`)
253
+ return { ...svc, source: svc.external ? top : svc.source }
254
+ }
255
+
256
+ /**
257
+ * One extra repo's worktree, placed inside the parent worktree at its mount,
258
+ * on the same branch. A repo from outside the workspace is also hidden from
259
+ * the parent's git status (info/exclude), as nested services usually are by
260
+ * .gitignore.
261
+ */
262
+ export async function addServiceWorktree(parentPath, svc, branch, { base } = {}) {
263
+ const target = join(parentPath, svc.rel)
264
+ try {
265
+ await mkdirp(dirname(target), { recursive: true })
266
+ const wt = await addWorktree(svc.source, branch, { path: target, base })
267
+ if (svc.external) await excludeInRepo(parentPath, svc.rel).catch(() => {})
268
+ const out = { ...wt, rel: svc.rel, source: svc.source, external: svc.external }
269
+ if (wt.path !== target) out.error = `${branch} is already checked out in ${svc.source} at ${wt.path}, so it is not in this worktree`
270
+ return out
271
+ } catch (err) {
272
+ return { rel: svc.rel, source: svc.source, external: svc.external, path: target, error: err.message }
273
+ }
274
+ }
275
+
276
+ async function excludeInRepo(dir, rel) {
277
+ const common = (await git(dir, ["rev-parse", "--git-common-dir"])).trim()
278
+ const file = join(resolve(dir, common), "info", "exclude")
279
+ const line = `/${rel}/`
280
+ const text = await readF(file, "utf8").catch(() => "")
281
+ if (text.split("\n").includes(line)) return
282
+ await mkdirp(dirname(file), { recursive: true })
283
+ await writeF(file, `${text}${text && !text.endsWith("\n") ? "\n" : ""}# a repo BotFarm mounts in its worktrees\n${line}\n`)
284
+ }
285
+
286
+ export async function removeWorktreeSet(worktreePath, { force = false } = {}) {
287
+ const removed = []
288
+ const nested = await nestedRepos(worktreePath).catch(() => [])
289
+ for (const child of nested) {
290
+ const common = await commonDir(child.path)
291
+ if (common && common !== child.path) {
292
+ await removeWorktree(common, child.path, { force }).catch(() => {})
293
+ removed.push(child.rel)
294
+ }
295
+ }
296
+ const common = await commonDir(worktreePath)
297
+ if (common && common !== worktreePath) await removeWorktree(common, worktreePath, { force })
298
+ return removed
299
+ }
300
+
301
+ /** Cheap status summary used on the session cards. */
302
+ export async function status(dir) {
303
+ dir = expandPath(dir)
304
+ try {
305
+ const [porcelain, shortstat] = await Promise.all([
306
+ git(dir, ["status", "--porcelain=v1", "-b"]),
307
+ git(dir, ["diff", "--shortstat", "HEAD"]).catch(() => ""),
308
+ ])
309
+ const lines = porcelain.split("\n").filter(Boolean)
310
+ const head = lines.find((l) => l.startsWith("##")) ?? ""
311
+ const files = lines.filter((l) => !l.startsWith("##"))
312
+ const m = /## (?<branch>[^.\s]+)(\.\.\.\S+)?( \[(?<track>[^\]]+)\])?/.exec(head)
313
+ const ss = /(\d+) insertion[^\d]*(\d+)? ?/.exec(shortstat)
314
+ const del = /(\d+) deletion/.exec(shortstat)
315
+ return {
316
+ branch: m?.groups?.branch ?? null,
317
+ tracking: m?.groups?.track ?? null,
318
+ dirty: files.length,
319
+ staged: files.filter((l) => l[0] !== " " && l[0] !== "?").length,
320
+ untracked: files.filter((l) => l.startsWith("??")).length,
321
+ insertions: ss ? Number(ss[1]) : 0,
322
+ deletions: del ? Number(del[1]) : 0,
323
+ files: files.slice(0, 40).map((l) => ({ code: l.slice(0, 2).trim(), path: l.slice(3) })),
324
+ }
325
+ } catch {
326
+ return null
327
+ }
328
+ }
329
+
330
+ export async function lastCommit(dir) {
331
+ try {
332
+ const out = await git(dir, ["log", "-1", "--pretty=%h %s"])
333
+ return out.trim()
334
+ } catch {
335
+ return null
336
+ }
337
+ }
338
+
339
+ /**
340
+ * Files changed against HEAD, for the task's diff view. Walks nested
341
+ * repositories as well: in a parent-plus-services layout the parent's git
342
+ * knows nothing about the changes that matter most.
343
+ */
344
+ export async function changedFiles(dir, { includeNested = true } = {}) {
345
+ if (!dir) return []
346
+ const own = await changedIn(dir)
347
+ if (!includeNested) return own
348
+ const nested = await nestedRepos(expandPath(dir)).catch(() => [])
349
+ for (const child of nested) {
350
+ for (const row of await changedIn(child.path)) {
351
+ own.push({ ...row, repo: child.rel, path: join(child.rel, row.path) })
352
+ }
353
+ }
354
+ return own
355
+ }
356
+
357
+ async function changedIn(dir) {
358
+ dir = expandPath(dir)
359
+ try {
360
+ const [tracked, untracked] = await Promise.all([
361
+ git(dir, ["diff", "--numstat", "HEAD"]).catch(() => ""),
362
+ git(dir, ["ls-files", "--others", "--exclude-standard"]).catch(() => ""),
363
+ ])
364
+ const rows = tracked
365
+ .split("\n")
366
+ .filter(Boolean)
367
+ .map((l) => {
368
+ const [add, del, path] = l.split("\t")
369
+ return { path, added: Number(add) || 0, removed: Number(del) || 0, untracked: false }
370
+ })
371
+ for (const path of untracked.split("\n").filter(Boolean)) {
372
+ rows.push({ path, added: 0, removed: 0, untracked: true })
373
+ }
374
+ return rows
375
+ } catch {
376
+ return []
377
+ }
378
+ }
379
+
380
+ export async function diffFile(dir, file) {
381
+ dir = expandPath(dir)
382
+ // The path may belong to a nested repo, in which case ask that repo.
383
+ const nested = await nestedRepos(dir).catch(() => [])
384
+ const owner = nested.find((c) => file.startsWith(c.rel + "/"))
385
+ if (owner) return diffIn(owner.path, file.slice(owner.rel.length + 1))
386
+ return diffIn(dir, file)
387
+ }
388
+
389
+ async function diffIn(dir, file) {
390
+ try {
391
+ const out = await git(dir, ["diff", "HEAD", "--", file])
392
+ if (out.trim()) return out
393
+ // untracked files have no diff, so show the file as added
394
+ const body = await git(dir, ["show", `:${file}`]).catch(() => null)
395
+ return body ?? (await import("node:fs/promises")).readFile(join(dir, file), "utf8").catch(() => "")
396
+ } catch {
397
+ return ""
398
+ }
399
+ }
400
+
401
+
402
+ /**
403
+ * A worktree only has what git tracks. opencode's project config — the MCP
404
+ * servers, agents, commands and rules a repository is set up with — is often
405
+ * untracked or ignored, so a bot in a fresh worktree would start without any
406
+ * of it. Copy whatever the checkout has and the worktree lacks; never
407
+ * overwrite something already there.
408
+ */
409
+ export const CONFIG_FILES = ["opencode.json", "opencode.jsonc", ".opencode", "AGENTS.md", "CLAUDE.md", ".mcp.json"]
410
+ const OWN_FILES = new Set(["opencode.json", "opencode.jsonc"]) // the ones botfarm may write to
411
+
412
+ export async function carryConfig(from, to) {
413
+ const carried = []
414
+ if (!from || !to || resolve(from) === resolve(to)) return carried
415
+ for (const name of CONFIG_FILES) {
416
+ const src = join(from, name)
417
+ let info
418
+ try { info = await stat(src) } catch { continue }
419
+ if (name === ".opencode" && info.isDirectory()) {
420
+ if (await linkOpencodeDir(src, join(to, name))) carried.push(name)
421
+ continue
422
+ }
423
+ await cp(src, join(to, name), { recursive: true, force: false, errorOnExist: false }).then(
424
+ () => carried.push(name),
425
+ () => {},
426
+ )
427
+ }
428
+ return carried
429
+ }
430
+
431
+ /**
432
+ * A worktree's .opencode must be a real folder botfarm can write its own MCP
433
+ * entry into — never a link to the checkout's folder, or the write lands in
434
+ * the user's shared config. Everything in it is linked back to the original
435
+ * (plugins, instructions, node_modules stay shared and current); only
436
+ * opencode.json(c), the file botfarm edits, is a real copy.
437
+ */
438
+ export async function linkOpencodeDir(src, dest) {
439
+ const real = await realpath(src).catch(() => null)
440
+ if (!real) return false
441
+ try {
442
+ const l = await lstat(dest)
443
+ if (l.isSymbolicLink()) await unlink(dest) // an older botfarm linked the whole folder
444
+ else if (!l.isDirectory()) return false
445
+ } catch {}
446
+ await mkdirp(dest, { recursive: true })
447
+ for (const name of await readdir(real)) {
448
+ const target = join(dest, name)
449
+ const exists = await lstat(target).then(() => true, () => false)
450
+ if (exists) continue
451
+ if (OWN_FILES.has(name)) await writeF(target, await readF(join(real, name)))
452
+ else await symlink(join(real, name), target).catch(() => {})
453
+ }
454
+ return true
455
+ }
456
+
457
+ /** Make sure <dir>/.opencode is a real folder before anything is written to it. */
458
+ export async function ensureOwnOpencodeDir(dir) {
459
+ const dest = join(dir, ".opencode")
460
+ const l = await lstat(dest).catch(() => null)
461
+ if (l?.isSymbolicLink()) return linkOpencodeDir(dest, dest + ".__tmp").then(async () => {
462
+ // Swap the link for the real folder built next to it.
463
+ const { rename } = await import("node:fs/promises")
464
+ await unlink(dest)
465
+ await rename(dest + ".__tmp", dest)
466
+ return true
467
+ })
468
+ await mkdirp(dest, { recursive: true })
469
+ const f = join(dest, "opencode.json")
470
+ const fl = await lstat(f).catch(() => null)
471
+ if (fl?.isSymbolicLink()) { const body = await readF(f); await unlink(f); await writeF(f, body) }
472
+ return true
473
+ }
474
+
475
+ // --- what a card changed, however it changed it ------------------------------
476
+ //
477
+ // Bots change files through opencode's own tools, through MCP servers (which
478
+ // write straight to disk), or by running scripts. None of that is visible in
479
+ // one place — but all of it is visible to git. So at the start of a card we
480
+ // record the content of every file that already differs from HEAD (as blobs in
481
+ // the repo's object store), and at the end we compare: any file whose content
482
+ // moved is a file this card changed, and the diff is from where it stood when
483
+ // the card began, not from HEAD.
484
+
485
+ const SNAP_MAX = 800
486
+
487
+ async function statusPaths(dir) {
488
+ const out = await git(dir, ["status", "--porcelain=v1", "-uall", "-z"]).catch(() => "")
489
+ const paths = []
490
+ const parts = out.split("\0").filter(Boolean)
491
+ for (let i = 0; i < parts.length; i++) {
492
+ const code = parts[i].slice(0, 2)
493
+ paths.push(parts[i].slice(3))
494
+ if (code.startsWith("R") || code.startsWith("C")) i++ // renames carry the old path next
495
+ }
496
+ return paths
497
+ }
498
+
499
+ async function blobOf(dir, file) {
500
+ const out = await git(dir, ["hash-object", "-w", "--", file]).catch(() => null)
501
+ return out ? out.trim() : null
502
+ }
503
+
504
+ async function headBlob(dir, file) {
505
+ const out = await git(dir, ["rev-parse", "--verify", "--quiet", `HEAD:${file}`]).catch(() => null)
506
+ return out ? out.trim() : null
507
+ }
508
+
509
+ /** { repos: { rel: { head, files: { path: blob|null } } } } for the worktree and its nested service repos. */
510
+ export async function snapshot(dir) {
511
+ dir = expandPath(dir)
512
+ if (!dir) return null
513
+ const repos = [{ rel: "", path: dir }, ...(await nestedRepos(dir).catch(() => [])).map((c) => ({ rel: c.rel, path: c.path }))]
514
+ const snap = { at: Date.now(), repos: {} }
515
+ let n = 0
516
+ for (const r of repos) {
517
+ const head = (await git(r.path, ["rev-parse", "HEAD"]).catch(() => "")).trim() || null
518
+ const files = {}
519
+ for (const f of await statusPaths(r.path)) {
520
+ if (n++ >= SNAP_MAX) break
521
+ files[f] = await blobOf(r.path, f)
522
+ }
523
+ snap.repos[r.rel] = { head, files }
524
+ }
525
+ return snap
526
+ }
527
+
528
+ /**
529
+ * Files whose content changed since `snap`, with a diff from then to now.
530
+ * Tool-agnostic: it only looks at the files on disk.
531
+ */
532
+ export async function changesSince(dir, snap, { maxFiles = 40, maxBytes = 160_000, perFile = 24_000 } = {}) {
533
+ dir = expandPath(dir)
534
+ if (!dir || !snap?.repos) return []
535
+ const out = []
536
+ let budget = maxBytes
537
+ for (const [rel, base] of Object.entries(snap.repos)) {
538
+ const repo = rel ? join(dir, rel) : dir
539
+ const head = (await git(repo, ["rev-parse", "HEAD"]).catch(() => "")).trim() || null
540
+ // Everything dirty now, plus everything that was dirty then (it may have been reverted),
541
+ // plus whatever a commit made during the card touched.
542
+ const paths = new Set([...(await statusPaths(repo)), ...Object.keys(base.files ?? {})])
543
+ if (head && base.head && head !== base.head) {
544
+ for (const f of (await git(repo, ["diff", "--name-only", base.head, head]).catch(() => "")).split("\n").filter(Boolean)) paths.add(f)
545
+ }
546
+ for (const f of paths) {
547
+ if (out.length >= maxFiles) break
548
+ const before = f in (base.files ?? {}) ? base.files[f] : base.head ? await git(repo, ["rev-parse", "--verify", "--quiet", `${base.head}:${f}`]).then((s) => s.trim() || null, () => null) : null
549
+ const exists = await stat(join(repo, f)).then((s) => s.isFile(), () => false)
550
+ const after = exists ? await blobOf(repo, f) : null
551
+ if (before === after) continue
552
+ let diff = ""
553
+ if (before && after) diff = await git(repo, ["diff", "--no-color", before, after]).catch(() => "")
554
+ else if (after) diff = (await git(repo, ["cat-file", "-p", after]).catch(() => "")).replace(/\n$/, "").split("\n").map((l) => "+" + l).join("\n")
555
+ else if (before) diff = (await git(repo, ["cat-file", "-p", before]).catch(() => "")).replace(/\n$/, "").split("\n").map((l) => "-" + l).join("\n")
556
+ const path = rel ? `${rel}/${f}` : f
557
+ // Blob diffs are headed with object ids; put the file name back.
558
+ diff = diff.replace(/^diff --git a\/\S+ b\/\S+\n/, "").replace(/^index [0-9a-f.]+\n/m, "").replace(/^--- .*\n\+\+\+ .*\n/m, "")
559
+ diff = `--- ${before ? "a/" + path : "/dev/null"}\n+++ ${after ? "b/" + path : "/dev/null"}\n${before && after ? "" : before ? "@@ deleted @@\n" : "@@ new file @@\n"}${diff}`
560
+ const lines = diff.split("\n")
561
+ const added = lines.filter((l) => l.startsWith("+") && !l.startsWith("+++")).length
562
+ const removed = lines.filter((l) => l.startsWith("-") && !l.startsWith("---")).length
563
+ const binary = /\0/.test(diff.slice(0, 8000)) || /\.(png|jpe?g|gif|webp|jar|class|zip|pdf)$/i.test(f)
564
+ const cap = Math.min(perFile, Math.max(0, budget))
565
+ const truncated = binary || diff.length > cap
566
+ const text = binary ? "" : diff.slice(0, cap)
567
+ budget -= text.length
568
+ out.push({ file: path, added: binary ? 0 : added, removed: binary ? 0 : removed, truncated, binary, created: !before, deleted: !after, diff: text })
569
+ }
570
+ }
571
+ return out
572
+ }
573
+
574
+ /** Did anything in the worktree change since `snap`? (Cheap: stops at the first change.) */
575
+ export async function changedSince(dir, snap) {
576
+ const c = await changesSince(dir, snap, { maxFiles: 1, maxBytes: 0, perFile: 0 }).catch(() => [])
577
+ return c.length > 0
578
+ }