@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.
- package/.idea/bot-farm.iml +9 -0
- package/.idea/misc.xml +6 -0
- package/.idea/modules.xml +8 -0
- package/.idea/vcs.xml +6 -0
- package/LICENSE +21 -0
- package/README.md +584 -0
- package/package.json +13 -0
- package/src/api.mjs +567 -0
- package/src/cli.mjs +241 -0
- package/src/craft.mjs +172 -0
- package/src/db.mjs +93 -0
- package/src/farmhands.mjs +111 -0
- package/src/git.mjs +578 -0
- package/src/identity.mjs +199 -0
- package/src/legacy-defaults.mjs +44 -0
- package/src/mcp.mjs +681 -0
- package/src/mesh.mjs +222 -0
- package/src/packets.mjs +125 -0
- package/src/patches.mjs +141 -0
- package/src/pipelines.mjs +2145 -0
- package/src/pricing.mjs +91 -0
- package/src/projects.mjs +148 -0
- package/src/public/classic.html +1810 -0
- package/src/public/index.html +3284 -0
- package/src/questions.mjs +69 -0
- package/src/quota.mjs +76 -0
- package/src/registry.mjs +88 -0
- package/src/replay.mjs +151 -0
- package/src/rooms.mjs +320 -0
- package/src/server.mjs +765 -0
- package/src/store.mjs +546 -0
- package/src/supervisor.mjs +1803 -0
- package/src/tasks.mjs +191 -0
- package/src/template.mjs +86 -0
- package/src/tools.mjs +241 -0
- package/src/workspaces.mjs +605 -0
- package/src/ws.mjs +130 -0
- package/src/yaml.mjs +266 -0
- package/test/board.mjs +108 -0
- package/test/craft.mjs +281 -0
- package/test/extend.mjs +145 -0
- package/test/flow.mjs +165 -0
- package/test/handoff.mjs +228 -0
- package/test/linked.mjs +83 -0
- package/test/loop.mjs +168 -0
- package/test/mock-opencode.mjs +217 -0
- package/test/parallel.mjs +135 -0
- package/test/patches.mjs +93 -0
- package/test/repos.mjs +118 -0
- package/test/roles.mjs +157 -0
- package/test/shot.mjs +65 -0
- package/test/smoke.mjs +70 -0
- package/test/transport.mjs +131 -0
- package/test/workspaces.mjs +228 -0
- 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
|
+
}
|