viewable-one 0.0.0-stage → 0.12.1
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/CHANGELOG.md +1460 -0
- package/LICENSE +21 -0
- package/README.md +755 -2
- package/SPAWN.md +2105 -0
- package/glytch.mjs +517 -0
- package/lib/acp.mjs +978 -0
- package/lib/admission.mjs +105 -0
- package/lib/agent-selection.mjs +123 -0
- package/lib/agents.mjs +913 -0
- package/lib/atomic.mjs +96 -0
- package/lib/auth.mjs +1387 -0
- package/lib/authenticatedHttp.mjs +68 -0
- package/lib/boardside.mjs +225 -0
- package/lib/child.mjs +1252 -0
- package/lib/cli-output.mjs +176 -0
- package/lib/cloudflared-release.json +25 -0
- package/lib/cloudflared.mjs +81 -0
- package/lib/codex-rpc.mjs +92 -0
- package/lib/codex.mjs +747 -0
- package/lib/codexApps.mjs +332 -0
- package/lib/codexPlanWindow.mjs +84 -0
- package/lib/codexToolInputs.mjs +139 -0
- package/lib/codingChecks.mjs +55 -0
- package/lib/compose.mjs +76 -0
- package/lib/computerApps.mjs +110 -0
- package/lib/conversationRules.mjs +45 -0
- package/lib/detect.mjs +605 -0
- package/lib/devserver.mjs +659 -0
- package/lib/digest.mjs +290 -0
- package/lib/doctor.mjs +464 -0
- package/lib/exitClass.mjs +64 -0
- package/lib/glytchFolder.mjs +105 -0
- package/lib/hosted.mjs +112 -0
- package/lib/imageAttachments.mjs +31 -0
- package/lib/init-connect.mjs +418 -0
- package/lib/kill.mjs +104 -0
- package/lib/logindaemon.mjs +976 -0
- package/lib/logs.mjs +120 -0
- package/lib/mcpjson.mjs +242 -0
- package/lib/memberPreview.mjs +165 -0
- package/lib/multiwatch.mjs +428 -0
- package/lib/pagePictures.mjs +396 -0
- package/lib/pageRoutes.mjs +67 -0
- package/lib/paths.mjs +9 -0
- package/lib/permissionAsk.mjs +418 -0
- package/lib/picker.mjs +300 -0
- package/lib/platform.mjs +60 -0
- package/lib/previewGateway.mjs +129 -0
- package/lib/previewTunnel.mjs +85 -0
- package/lib/procs.mjs +428 -0
- package/lib/project-mcp.mjs +288 -0
- package/lib/projectPaths.mjs +31 -0
- package/lib/refusal.mjs +70 -0
- package/lib/registry.mjs +204 -0
- package/lib/repomap.mjs +322 -0
- package/lib/restartPass.mjs +151 -0
- package/lib/retired-address.mjs +78 -0
- package/lib/rowsAnswers.mjs +389 -0
- package/lib/rowsEvents.mjs +86 -0
- package/lib/run-engine.mjs +16 -0
- package/lib/runner-health.mjs +57 -0
- package/lib/runner.mjs +1569 -0
- package/lib/runnerAdmission.mjs +35 -0
- package/lib/runnerApi.mjs +312 -0
- package/lib/runnerCompletion.mjs +406 -0
- package/lib/runnerLease.mjs +37 -0
- package/lib/runnerMaintenance.mjs +27 -0
- package/lib/runnerPace.mjs +283 -0
- package/lib/runnerPerformance.mjs +26 -0
- package/lib/runnerRealtime.mjs +340 -0
- package/lib/runnerRegister.mjs +96 -0
- package/lib/runnerSettings.mjs +40 -0
- package/lib/runnerSettlement.mjs +138 -0
- package/lib/runnerWorkspace.mjs +162 -0
- package/lib/serverSweep.mjs +90 -0
- package/lib/sharedPreview.mjs +292 -0
- package/lib/signIn.mjs +47 -0
- package/lib/spawn.mjs +330 -0
- package/lib/sweep.mjs +161 -0
- package/lib/sync.mjs +484 -0
- package/lib/uninstall-project.mjs +588 -0
- package/lib/uninstall.mjs +457 -0
- package/lib/update.mjs +302 -0
- package/lib/usageLimit.mjs +108 -0
- package/lib/windows-command.mjs +85 -0
- package/lib/worktree.mjs +1138 -0
- package/lib/writePolicy.mjs +45 -0
- package/package.json +58 -4
- package/spawn-runner.mjs +167 -0
package/lib/worktree.mjs
ADDED
|
@@ -0,0 +1,1138 @@
|
|
|
1
|
+
// A spawned session gets its own git worktree, so a bad run cannot touch the
|
|
2
|
+
// checkout the user is looking at.
|
|
3
|
+
//
|
|
4
|
+
// The ruling, 2026-08-26: "Worktree yes!". The gap it closes was found while
|
|
5
|
+
// the first real board-spawned session was still running: the runner passes
|
|
6
|
+
// `cwd: request.cwd || root`, and a board request deliberately carries no cwd
|
|
7
|
+
// (a browser directory handle has a name, not a path), so every spawned
|
|
8
|
+
// session was landing in the MAIN checkout, on whatever branch was checked
|
|
9
|
+
// out - which was `main`. Every session dispatched by hand that night ran in
|
|
10
|
+
// an isolated worktree and got that safety for free; the ones spawned from
|
|
11
|
+
// the canvas, the whole point of the feature, did not. The brief asked them
|
|
12
|
+
// nicely to work on a branch. Nothing enforced it.
|
|
13
|
+
//
|
|
14
|
+
// TWO THINGS THIS MUST NOT BREAK, both learned the hard way:
|
|
15
|
+
//
|
|
16
|
+
// `canvas/` and `inbox/` are GITIGNORED, so a worktree has neither. A session
|
|
17
|
+
// whose board root resolved to its own worktree would write ops files into a
|
|
18
|
+
// folder the app has never heard of - the call succeeds, an op id comes back,
|
|
19
|
+
// and nothing ever reaches the canvas (bridge/README.md, "Sessions in a git
|
|
20
|
+
// worktree"). The child's PM_BOARD_ROOT already points at the real linked
|
|
21
|
+
// folder and MUST keep doing so; that is why this returns a cwd and touches
|
|
22
|
+
// nothing else about the environment.
|
|
23
|
+
//
|
|
24
|
+
// `node_modules` is not in a fresh worktree either, and a session that cannot
|
|
25
|
+
// run `npx vitest` cannot verify what it built. It is symlinked rather than
|
|
26
|
+
// installed: an install per spawn costs minutes and disk for a tree that is
|
|
27
|
+
// usually thrown away.
|
|
28
|
+
|
|
29
|
+
import { execFile } from "node:child_process";
|
|
30
|
+
import { symlink, mkdir, rm, access, readFile, writeFile, stat, lstat } from "node:fs/promises";
|
|
31
|
+
import { hostname } from "node:os";
|
|
32
|
+
import { canonicalPath, staysInProject } from "./projectPaths.mjs";
|
|
33
|
+
import { basename, isAbsolute, join, resolve, sep } from "node:path";
|
|
34
|
+
// THE PROCESS KILLING LIVES IN procs.mjs since 2026-09-02: it is about
|
|
35
|
+
// processes, not about git, and both of its functions had already learned the
|
|
36
|
+
// same "whose process is this?" lesson the hard way. Re-exported below so no
|
|
37
|
+
// importer and no test changed.
|
|
38
|
+
// GLYTCH lives in procs.mjs and is re-exported here. It is the prefix every
|
|
39
|
+
// board-spawned tree and branch carries, and procs.mjs is where it is
|
|
40
|
+
// MATCHED - the reaper's whole first guard is "is this directory one
|
|
41
|
+
// makeWorktree could have made?". Putting it there rather than importing it
|
|
42
|
+
// from here is what keeps these two modules a straight line instead of a
|
|
43
|
+
// cycle, which is the thing check-portable --strict now looks for.
|
|
44
|
+
import { CHAT_PREFIX, GLYTCH, killProcessesIn } from "./procs.mjs";
|
|
45
|
+
export { CHAT_PREFIX, GLYTCH };
|
|
46
|
+
export { isOursToKill, parseProcessTable, reapOrphanedWorktreeProcesses, reapableWorktreeName } from "./procs.mjs";
|
|
47
|
+
|
|
48
|
+
/** How `node_modules` is linked into a fresh worktree.
|
|
49
|
+
*
|
|
50
|
+
* A DIRECTORY SYMLINK ON WINDOWS NEEDS DEVELOPER MODE OR ADMIN. Without one it
|
|
51
|
+
* throws EPERM, which the caller catches and logs, and the session then cannot
|
|
52
|
+
* run the project's own tests - which is most of what a brief asks it to do.
|
|
53
|
+
* A junction needs no privilege, works for a local absolute directory (which
|
|
54
|
+
* this always is: it is `<root>/node_modules`), and is what npm itself uses on
|
|
55
|
+
* Windows. Brief 21, session 3. Exported so the choice can be pinned from a
|
|
56
|
+
* Mac rather than only observed on Windows. */
|
|
57
|
+
export const nodeModulesLinkType = (platform = process.platform) => (platform === "win32" ? "junction" : "dir");
|
|
58
|
+
|
|
59
|
+
// Exported (2026-09-11, brief 30 Move 2) so `runner.mjs`'s rows-based
|
|
60
|
+
// admission check can run the same merge-base check
|
|
61
|
+
// this file already does, without a second copy of the `execFile` wrapper.
|
|
62
|
+
export const git = (args, cwd) =>
|
|
63
|
+
new Promise((resolve) => {
|
|
64
|
+
execFile("git", args, { cwd, maxBuffer: 8 * 1024 * 1024 }, (error, stdout, stderr) => {
|
|
65
|
+
resolve({
|
|
66
|
+
ok: !error,
|
|
67
|
+
// The exit status, for the few commands whose non-zero exit is an
|
|
68
|
+
// answer rather than a failure (`merge-tree` exits 1 on a conflict).
|
|
69
|
+
code: error ? (typeof error.code === "number" ? error.code : -1) : 0,
|
|
70
|
+
stdout: String(stdout ?? ""),
|
|
71
|
+
stderr: String(stderr ?? ""),
|
|
72
|
+
detail: error ? String(stderr || error.message).trim() : "",
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
/** Is `dir` inside a git checkout? False when git says no or cannot run. */
|
|
78
|
+
export async function isGitCheckout(dir) {
|
|
79
|
+
const inRepo = await git(["rev-parse", "--is-inside-work-tree"], dir);
|
|
80
|
+
return inRepo.ok && inRepo.stdout.trim() === "true";
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const exists = async (path) => {
|
|
84
|
+
try {
|
|
85
|
+
await access(path);
|
|
86
|
+
return true;
|
|
87
|
+
} catch {
|
|
88
|
+
return false;
|
|
89
|
+
}
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
// PER-REPO LOCK for git mutations. makeWorktree, cleanWorktree, reuseWorktree,
|
|
93
|
+
// mergeWorktree and deleteWorktree on the same root run one at a time. Reads
|
|
94
|
+
// (branchOfWorktree, worktreeSummary) stay unlocked.
|
|
95
|
+
//
|
|
96
|
+
// It is TWO locks, because there are two ways to collide.
|
|
97
|
+
//
|
|
98
|
+
// IN-PROCESS, a promise chain keyed by repo root: two sessions finishing
|
|
99
|
+
// inside one runner can no longer both `git checkout main` and merge at once.
|
|
100
|
+
// This is the lock that has been here since 2026-09-01.
|
|
101
|
+
//
|
|
102
|
+
// CROSS-PROCESS, a lockdir under the repo's own `.git/`. The comment above
|
|
103
|
+
// used to say "one runner process owns a root (the claim files keep two
|
|
104
|
+
// runners apart), so an in-process lock is enough", and that was not true of
|
|
105
|
+
// the code standing beside it: `spawn-runner.mjs merge <path>` and
|
|
106
|
+
// `spawn-runner.mjs delete-worktree <path>` are a SECOND process, typed by a
|
|
107
|
+
// person, calling straight into mergeWorktree while a watcher is running. The
|
|
108
|
+
// claim files keep two runners from taking the same REQUEST; they say nothing
|
|
109
|
+
// about two processes running `git checkout main` in the same second.
|
|
110
|
+
//
|
|
111
|
+
// `mkdir` is the primitive because it is the one atomic exclusive create every
|
|
112
|
+
// filesystem agrees on - the same reasoning the claim file uses `flag: "wx"`.
|
|
113
|
+
// The owner file inside it is who holds it, and it is how a lock survives its
|
|
114
|
+
// holder being killed: a pid that is gone on this host frees it immediately, a
|
|
115
|
+
// lock from another host frees on age alone. Waiting is bounded and losing the
|
|
116
|
+
// wait is not fatal - it logs loudly and proceeds, because refusing to clean up
|
|
117
|
+
// a worktree is worse than the small race that was there all along.
|
|
118
|
+
const lockChains = new Map(); // root -> Promise that resolves when the lock is free
|
|
119
|
+
|
|
120
|
+
/** How long a lockdir is believed before it is treated as abandoned. Git
|
|
121
|
+
* mutations here take seconds; ten minutes is a crashed holder, not a busy
|
|
122
|
+
* one. */
|
|
123
|
+
export const LOCK_STALE_MS = 10 * 60_000;
|
|
124
|
+
/** How long to wait for another process to finish before going ahead anyway. */
|
|
125
|
+
export const LOCK_WAIT_MS = 60_000;
|
|
126
|
+
const LOCK_POLL_MS = 150;
|
|
127
|
+
|
|
128
|
+
// INSIDE `.git/`, and the reason is not tidiness. mergeWorktreeImpl refuses to
|
|
129
|
+
// merge into a dirty checkout, and rightly - so a lock that git can SEE makes
|
|
130
|
+
// every merge refuse itself. `.git/` is the one directory `git status` never
|
|
131
|
+
// reports, it is exactly per-repo, and it is already where git keeps its own
|
|
132
|
+
// locks. When `.git` is a file rather than a directory (this root is itself a
|
|
133
|
+
// linked worktree) the mkdir fails, and acquireFileLock says so and proceeds.
|
|
134
|
+
const lockDirFor = (root) => join(resolve(root), ".git", "pm-repo.lock");
|
|
135
|
+
|
|
136
|
+
const stillAlive = (pid) => {
|
|
137
|
+
try {
|
|
138
|
+
process.kill(pid, 0);
|
|
139
|
+
return true;
|
|
140
|
+
} catch (error) {
|
|
141
|
+
// EPERM means it exists and belongs to somebody else.
|
|
142
|
+
return error?.code === "EPERM";
|
|
143
|
+
}
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
/** Take the cross-process lock. Returns a release function, or null when the
|
|
147
|
+
* wait ran out - the caller proceeds either way, and says so. */
|
|
148
|
+
async function acquireFileLock(root, log) {
|
|
149
|
+
const dir = lockDirFor(root);
|
|
150
|
+
const startedAt = Date.now();
|
|
151
|
+
const deadline = startedAt + LOCK_WAIT_MS;
|
|
152
|
+
const mine = { pid: process.pid, host: hostname(), at: Date.now() };
|
|
153
|
+
// WAITED, NAMED (2026-09-21, the claim-to-spawn gap). `busy` is only true
|
|
154
|
+
// once the FIRST `mkdir` has already lost to EEXIST - so a lock taken on
|
|
155
|
+
// the first try logs nothing, same as before, and only a caller that
|
|
156
|
+
// actually queued behind another holder pays for a line. `lastOwner` is
|
|
157
|
+
// whichever `owner.json` was last read while waiting, so the eventual
|
|
158
|
+
// success can say who it waited on even though that holder may have
|
|
159
|
+
// released and vanished by the time this loop wins.
|
|
160
|
+
let busy = false;
|
|
161
|
+
let lastOwner = null;
|
|
162
|
+
for (;;) {
|
|
163
|
+
try {
|
|
164
|
+
await mkdir(dir); // EEXIST when somebody holds it: the whole of the lock
|
|
165
|
+
await writeFile(join(dir, "owner.json"), `${JSON.stringify(mine)}\n`, "utf8").catch(() => {});
|
|
166
|
+
if (busy) {
|
|
167
|
+
const waitedSecs = ((Date.now() - startedAt) / 1000).toFixed(1);
|
|
168
|
+
log(
|
|
169
|
+
`took the repo lock at ${dir} after waiting ${waitedSecs}s ` +
|
|
170
|
+
`(previously held by pid ${lastOwner?.pid ?? "?"} on ${lastOwner?.host ?? "?"})`,
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
return async () => { await rm(dir, { recursive: true, force: true }).catch(() => {}); };
|
|
174
|
+
} catch (error) {
|
|
175
|
+
if (error?.code !== "EEXIST") {
|
|
176
|
+
// No lock is better than no worktree operation: a `.git` that is a
|
|
177
|
+
// file (this root is a linked worktree), a read-only checkout, or a
|
|
178
|
+
// filesystem that will not mkdir must not stop a merge.
|
|
179
|
+
log(`could not take the repo lock (${error?.code ?? error}); proceeding without it`);
|
|
180
|
+
return null;
|
|
181
|
+
}
|
|
182
|
+
busy = true;
|
|
183
|
+
let owner = null;
|
|
184
|
+
try { owner = JSON.parse(await readFile(join(dir, "owner.json"), "utf8")); } catch { /* mid-write, or crashed between mkdir and write */ }
|
|
185
|
+
if (owner) lastOwner = owner;
|
|
186
|
+
// A holder that just won the `mkdir` has not written `owner.json` yet,
|
|
187
|
+
// so a reader landing in that gap sees the same ENOENT a crashed holder
|
|
188
|
+
// would leave forever. Treating "unreadable" as "infinitely old" (age
|
|
189
|
+
// measured from `at: 0`) cleared a LIVE lock out from under its holder
|
|
190
|
+
// on every such read - a real cross-process race, not just a test one.
|
|
191
|
+
// Falling back to the lock DIRECTORY's own age keeps a lock that was
|
|
192
|
+
// just taken safe (its mtime is now) while still reclaiming one whose
|
|
193
|
+
// holder crashed before ever writing owner.json (the dir ages past
|
|
194
|
+
// LOCK_STALE_MS same as always).
|
|
195
|
+
let age;
|
|
196
|
+
if (owner) {
|
|
197
|
+
age = Date.now() - (Number(owner.at) || 0);
|
|
198
|
+
} else {
|
|
199
|
+
const dirStat = await stat(dir).catch(() => null);
|
|
200
|
+
age = dirStat ? Date.now() - dirStat.mtimeMs : 0;
|
|
201
|
+
}
|
|
202
|
+
const deadHolder = owner?.host === mine.host && Number.isFinite(owner?.pid) && !stillAlive(owner.pid);
|
|
203
|
+
if (deadHolder || age > LOCK_STALE_MS) {
|
|
204
|
+
log(`clearing a ${deadHolder ? "dead" : "stale"} repo lock at ${dir} (pid ${owner?.pid ?? "?"})`);
|
|
205
|
+
await rm(dir, { recursive: true, force: true }).catch(() => {});
|
|
206
|
+
continue;
|
|
207
|
+
}
|
|
208
|
+
if (Date.now() >= deadline) {
|
|
209
|
+
log(
|
|
210
|
+
`waited ${Math.round(LOCK_WAIT_MS / 1000)}s for the repo lock held by pid ${owner?.pid ?? "?"} ` +
|
|
211
|
+
`on ${owner?.host ?? "?"}; going ahead anyway`,
|
|
212
|
+
);
|
|
213
|
+
return null;
|
|
214
|
+
}
|
|
215
|
+
await new Promise((r) => setTimeout(r, LOCK_POLL_MS));
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** Acquire the in-process lock for the given repo root. Returns a release
|
|
221
|
+
* function. */
|
|
222
|
+
async function acquireRepoLock(root) {
|
|
223
|
+
const normalized = resolve(root); // normalize paths so /foo and /foo/ are the same key
|
|
224
|
+
const existingChain = lockChains.get(normalized) ?? Promise.resolve();
|
|
225
|
+
let release;
|
|
226
|
+
const newChain = new Promise((res) => { release = res; });
|
|
227
|
+
lockChains.set(normalized, newChain);
|
|
228
|
+
await existingChain;
|
|
229
|
+
return release;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** Wrap a worktree operation with the repo lock, both halves. In-process
|
|
233
|
+
* first, so a queue of callers inside this process waits on a promise rather
|
|
234
|
+
* than on a directory. */
|
|
235
|
+
export async function withRepoLock(root, fn, log = () => {}) {
|
|
236
|
+
const release = await acquireRepoLock(root);
|
|
237
|
+
let releaseFile = null;
|
|
238
|
+
try {
|
|
239
|
+
releaseFile = await acquireFileLock(root, log);
|
|
240
|
+
return await fn();
|
|
241
|
+
} finally {
|
|
242
|
+
if (releaseFile) await releaseFile();
|
|
243
|
+
release();
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** The repo's default branch, LOCAL only: `main` if `refs/heads/main` exists,
|
|
248
|
+
* else `master` if that exists, else null.
|
|
249
|
+
*
|
|
250
|
+
* PROVEN 2026-09-21: `makeWorktreeImpl` branched off `git rev-parse HEAD` -
|
|
251
|
+
* whatever the main checkout happened to have checked out, not the repo's
|
|
252
|
+
* actual default branch. A run based on a side branch someone left checked
|
|
253
|
+
* out, and `mergeWorktreeImpl` (which checks out literal `main` and merges
|
|
254
|
+
* into it) would have dragged that whole side branch into main. A run's
|
|
255
|
+
* worktree must instead base on the SAME ref the merge op merges into.
|
|
256
|
+
*
|
|
257
|
+
* LOCAL over `origin/*`: the merge op merges into the local branch of that
|
|
258
|
+
* name, so the base must be the same ref - basing off `origin/main` while
|
|
259
|
+
* local `main` has diverged would silently drop or duplicate commits at
|
|
260
|
+
* merge time. `--verify --quiet` against `refs/heads/<name>` so a detached
|
|
261
|
+
* HEAD or a missing branch is a clean `null`, not a thrown error. */
|
|
262
|
+
export async function defaultBranchOf(root) {
|
|
263
|
+
for (const name of ["main", "master"]) {
|
|
264
|
+
const check = await git(["rev-parse", "--verify", "--quiet", `refs/heads/${name}`], root);
|
|
265
|
+
if (check.ok) return name;
|
|
266
|
+
}
|
|
267
|
+
return null;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** The prompt's own name, made safe for a git ref and a directory.
|
|
271
|
+
*
|
|
272
|
+
* Only [a-z0-9-] survives, which is what keeps the result a legal ref: every
|
|
273
|
+
* shape git refuses (a leading dash, `..`, `@{`, a `.lock` ending, a space)
|
|
274
|
+
* is spelled with a character this drops. Capped so a 60-character title does
|
|
275
|
+
* not become a 60-character path segment; the tail below is what makes it
|
|
276
|
+
* unique, so cutting here loses traceability, never correctness. */
|
|
277
|
+
export const slugForTitle = (title) =>
|
|
278
|
+
String(title ?? "")
|
|
279
|
+
.toLowerCase()
|
|
280
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
281
|
+
.replace(/^-+|-+$/g, "")
|
|
282
|
+
.slice(0, 40)
|
|
283
|
+
.replace(/-+$/g, "");
|
|
284
|
+
|
|
285
|
+
/** The name both the tree and the branch are built from: the prompt, then the
|
|
286
|
+
* request id.
|
|
287
|
+
*
|
|
288
|
+
* THE ID STAYS, and it is not decoration. Two runs of the same prompt would
|
|
289
|
+
* otherwise ask for the same branch, `git worktree add -b` would fail, and
|
|
290
|
+
* makeWorktree's failure path is "run in the linked checkout instead" - so a
|
|
291
|
+
* name collision would silently put the second session on his own working
|
|
292
|
+
* tree. It also keeps the tree traceable back to the file that asked for it,
|
|
293
|
+
* which is what the id was for before the prompt name joined it. Its `spawn-`
|
|
294
|
+
* prefix is dropped: `glytch` already says what this is. */
|
|
295
|
+
export const worktreeStem = (id, title) => {
|
|
296
|
+
const slug = slugForTitle(title);
|
|
297
|
+
const tail = String(id ?? "").replace(/^spawn-/, "");
|
|
298
|
+
return [slug, tail].filter((part) => part !== "").join("-") || "session";
|
|
299
|
+
};
|
|
300
|
+
|
|
301
|
+
/** Where a request's worktree and branch live. A directory cannot hold the
|
|
302
|
+
* branch's `/`, so the two differ by that one character and nothing else. */
|
|
303
|
+
export const worktreeName = (id, title) => `${GLYTCH}-${worktreeStem(id, title)}`;
|
|
304
|
+
export const worktreePath = (root, id, title) =>
|
|
305
|
+
join(root, ".claude", "worktrees", worktreeName(id, title));
|
|
306
|
+
export const worktreeBranch = (id, title) => `${GLYTCH}/${worktreeStem(id, title)}`;
|
|
307
|
+
|
|
308
|
+
/** True only when `target` resolves to a path strictly inside
|
|
309
|
+
* `<root>/.claude/worktrees/` — the one directory makeWorktree ever creates.
|
|
310
|
+
*
|
|
311
|
+
* The merge/delete SIGNAL FILES carry the worktree path to act on, and until
|
|
312
|
+
* this nothing validated it: `killProcessesIn` SIGTERMs every process with a
|
|
313
|
+
* file open under the path, then `rm -rf` takes the directory, both twice.
|
|
314
|
+
* A `.delete` file containing {"worktreePath":"/Users/<me>"} would kill every
|
|
315
|
+
* process holding a file under home and recursively delete it. Board content
|
|
316
|
+
* is an injection surface (a spawned session can write into inbox/), so the
|
|
317
|
+
* path a request can name is fenced to where spawns actually live. Resolving
|
|
318
|
+
* an absolute `target` ignores `root`, which is exactly the escape we block;
|
|
319
|
+
* the trailing `sep` stops `.claude/worktrees-evil` passing as a sibling. */
|
|
320
|
+
export function isContainedWorktree(root, target) {
|
|
321
|
+
if (typeof target !== "string" || target.trim() === "") return false;
|
|
322
|
+
const base = resolve(root, ".claude", "worktrees");
|
|
323
|
+
const abs = resolve(root, target);
|
|
324
|
+
return abs !== base && (abs + sep).startsWith(base + sep) && staysInProject(root, abs);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/** A directory inside the tree folder is not necessarily this repository's
|
|
328
|
+
* worktree. Destructive operations require Git's exact registration too. */
|
|
329
|
+
async function registeredWorktree(root, target) {
|
|
330
|
+
if (!isContainedWorktree(root, target)) return false;
|
|
331
|
+
const listed = await git(["worktree", "list", "--porcelain", "-z"], root);
|
|
332
|
+
if (!listed.ok) return false;
|
|
333
|
+
try {
|
|
334
|
+
const canonical = canonicalPath(target);
|
|
335
|
+
return listed.stdout.split("\0").some((field) => {
|
|
336
|
+
if (!field.startsWith("worktree ")) return false;
|
|
337
|
+
try { return canonicalPath(field.slice(9)) === canonical; } catch { return false; }
|
|
338
|
+
});
|
|
339
|
+
} catch { return false; }
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// ---------- THE CHAT WORKTREE (brief 25, ruling 1, 2026-09-07) ----------
|
|
343
|
+
//
|
|
344
|
+
// "One long-lived chat worktree per agent per folder,
|
|
345
|
+
// `<root>/.claude/worktrees/chat-<agentId>`, made once and kept." A chat must
|
|
346
|
+
// not mint a tree per MESSAGE - thirty messages would be thirty trees and
|
|
347
|
+
// thirty branches, and resume would break on every one of them because Claude
|
|
348
|
+
// Code keys its conversations to a working directory.
|
|
349
|
+
//
|
|
350
|
+
// So this half of the file is the opposite of the half above it. A session's
|
|
351
|
+
// tree is made per run and thrown away by cleanWorktree when the run left
|
|
352
|
+
// nothing in it; a chat tree is made ONCE, on the first message that finds it
|
|
353
|
+
// missing, and is removed only when the AGENT is deleted. Nothing here
|
|
354
|
+
// removes one, cleanWorktreeImpl refuses to (below), and the orphan reaper
|
|
355
|
+
// steps over the name (procs.mjs, reapableWorktreeName).
|
|
356
|
+
|
|
357
|
+
/** True when `target` is a chat worktree of this folder: inside the one
|
|
358
|
+
* directory makeWorktree ever creates AND named `chat-<agentId>`.
|
|
359
|
+
*
|
|
360
|
+
* Both halves matter. The containment fence is isContainedWorktree's, for its
|
|
361
|
+
* reason (a request file is an injection surface and the path it names must
|
|
362
|
+
* not be able to say "up"); the name is what separates an agent's kept tree
|
|
363
|
+
* from a session's disposable one. */
|
|
364
|
+
export function isChatWorktreePath(root, target) {
|
|
365
|
+
if (!isContainedWorktree(root, target)) return false;
|
|
366
|
+
return basename(resolve(root, target)).startsWith(CHAT_PREFIX);
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/** The branch a chat worktree sits on: `glytch/chat-<agentId>`.
|
|
370
|
+
*
|
|
371
|
+
* Read OFF THE PATH rather than off a new request field, because the path
|
|
372
|
+
* already carries the agent id - the app builds `.claude/worktrees/chat-
|
|
373
|
+
* <agentId>` and that name is the only thing on either side of the contract
|
|
374
|
+
* that has to agree. `glytch/` is the prefix his 2026-08-28 ask put on every
|
|
375
|
+
* board-made branch so `git branch` correlates with the canvas, and a chat
|
|
376
|
+
* tree is board-made like any other. */
|
|
377
|
+
export const chatWorktreeBranch = (path) => `${GLYTCH}/${basename(path)}`;
|
|
378
|
+
|
|
379
|
+
/** WHERE A CHAT TURN RUNS, as an absolute path, or a refusal in words.
|
|
380
|
+
*
|
|
381
|
+
* The app writes `cwd` RELATIVE to the folder root and cannot do otherwise: a
|
|
382
|
+
* browser directory handle has a name, not a path (see the `cwd` note in
|
|
383
|
+
* src/folder/spawnRequest.ts), so it must not invent an absolute one. A cwd
|
|
384
|
+
* written by the CLI is absolute and is used as it stands. From the second
|
|
385
|
+
* turn on the app hands back the runner's own absolute `worktreePath` as the
|
|
386
|
+
* cwd, so only a first turn is ever relative.
|
|
387
|
+
*
|
|
388
|
+
* ANYTHING THAT ESCAPES THE FOLDER IS REFUSED, `..` and an absolute path
|
|
389
|
+
* outside it alike. A chat turn holds Write, Edit and Bash, and the folder is
|
|
390
|
+
* the boundary the person linked; a path outside it is either a mistake or
|
|
391
|
+
* somebody else's idea, and neither is worth guessing at. The root ITSELF is
|
|
392
|
+
* allowed, because a caller naming it is a caller who said so - the thing
|
|
393
|
+
* ruling 1 refuses is a silent FALLBACK to his checkout, which nothing here
|
|
394
|
+
* does. */
|
|
395
|
+
export function resolveChatCwd(root, cwd) {
|
|
396
|
+
const named = typeof cwd === "string" ? cwd.trim() : "";
|
|
397
|
+
if (named === "") {
|
|
398
|
+
return { ok: false, reason: "A chat turn must name cwd: the directory the conversation lives in." };
|
|
399
|
+
}
|
|
400
|
+
return resolveContainedCwd(root, named, "a chat conversation");
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/** The same fence for any request that names a `cwd` (#324): an ordinary
|
|
404
|
+
* run holds Write, Edit and Bash too, so a cwd that resolves outside the
|
|
405
|
+
* linked folder, or through a symbolic link, is refused rather than run in.
|
|
406
|
+
* `what` names the thing in the refusal. */
|
|
407
|
+
export function resolveContainedCwd(root, cwd, what = "a run") {
|
|
408
|
+
const named = typeof cwd === "string" ? cwd.trim() : "";
|
|
409
|
+
const base = resolve(root);
|
|
410
|
+
const path = isAbsolute(named) ? resolve(named) : resolve(base, named);
|
|
411
|
+
if (named === "" || (path !== base && !(path + sep).startsWith(base + sep)) || !staysInProject(root, path)) {
|
|
412
|
+
return {
|
|
413
|
+
ok: false,
|
|
414
|
+
path,
|
|
415
|
+
reason:
|
|
416
|
+
`it resolves to ${path}, which is outside the folder this runner watches (${base}) or traverses a symbolic link. ` +
|
|
417
|
+
`A relative cwd is resolved against that folder and an absolute one is used as it stands; ` +
|
|
418
|
+
`either way ${what} runs inside the linked folder or it does not run.`,
|
|
419
|
+
};
|
|
420
|
+
}
|
|
421
|
+
return { ok: true, path };
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/** Mint the chat worktree, once, for a turn that found it missing.
|
|
425
|
+
*
|
|
426
|
+
* `mintIfMissing: true` on the request is what asks for this, and it is asked
|
|
427
|
+
* for because THE APP CANNOT MAKE A WORKTREE. Ruling 1's directory has to
|
|
428
|
+
* exist before the first message can run in it, and the browser can only
|
|
429
|
+
* write a file. Session 1's refusal stands untouched for a request WITHOUT
|
|
430
|
+
* the flag: a missing directory with nothing asking for one is the
|
|
431
|
+
* conversation being gone, and falling back to the linked checkout would put
|
|
432
|
+
* a chat agent in the tree he is working in.
|
|
433
|
+
*
|
|
434
|
+
* Idempotent by construction: the caller only reaches this when the directory
|
|
435
|
+
* is not there, so a tree that already exists is used as it stands and a
|
|
436
|
+
* swept one comes back on the next message instead of failing it. The BRANCH
|
|
437
|
+
* may outlive the directory (someone `rm -rf`'d the tree, or `git worktree
|
|
438
|
+
* remove` left the branch), so an existing branch is checked out into the new
|
|
439
|
+
* tree rather than re-created - a `-b` onto a name git already knows fails,
|
|
440
|
+
* and failing here means a message he typed does not run.
|
|
441
|
+
*
|
|
442
|
+
* Returns `{ ok: true, path, branch, base, detail }`, or `{ ok: false,
|
|
443
|
+
* reason }` in words a person reads in the transcript. It never falls back
|
|
444
|
+
* the way makeWorktreeImpl does, and that is the difference between the two:
|
|
445
|
+
* a session that could not get a tree still has somewhere to run, and a chat
|
|
446
|
+
* turn's tree IS which conversation this is. */
|
|
447
|
+
async function makeChatWorktreeImpl(root, path, log = () => {}) {
|
|
448
|
+
// A CHAT NEEDS A GIT REPO FOR ITS OWN WORKTREE. There is no unisolated
|
|
449
|
+
// fallback to offer: ruling 1 gives the agent a tree so it can edit and
|
|
450
|
+
// commit without touching his checkout, and a folder that is not a
|
|
451
|
+
// repository cannot give it one.
|
|
452
|
+
const inRepo = await git(["rev-parse", "--is-inside-work-tree"], root);
|
|
453
|
+
if (!inRepo.ok || inRepo.stdout.trim() !== "true") {
|
|
454
|
+
return {
|
|
455
|
+
ok: false,
|
|
456
|
+
reason:
|
|
457
|
+
`${root} is not a git repository, and a chat with a coding agent needs one: the agent ` +
|
|
458
|
+
`gets its OWN long-lived worktree (${path}) so it can read, edit and commit without ` +
|
|
459
|
+
`touching the checkout you are working in, and a worktree can only be cut from a repo. ` +
|
|
460
|
+
`Link a folder that is a git checkout, or run \`git init\` in this one, and send the ` +
|
|
461
|
+
`message again.`,
|
|
462
|
+
};
|
|
463
|
+
}
|
|
464
|
+
// The one place a chat tree may be made, so an injected request file cannot
|
|
465
|
+
// turn "mint if missing" into "create a directory anywhere in my folder".
|
|
466
|
+
if (!isChatWorktreePath(root, path)) {
|
|
467
|
+
return {
|
|
468
|
+
ok: false,
|
|
469
|
+
reason:
|
|
470
|
+
`${path} is not a chat worktree path. The runner mints one only at ` +
|
|
471
|
+
`<folder>/.claude/worktrees/chat-<agentId> (his ruling 1: one long-lived chat worktree ` +
|
|
472
|
+
`per agent per folder), so nothing was created and nothing was spawned.`,
|
|
473
|
+
};
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
await ensureIgnored(root, log);
|
|
477
|
+
|
|
478
|
+
const branch = chatWorktreeBranch(path);
|
|
479
|
+
// THE SAME BASE makeWorktreeImpl PICKS, and for its reason: the repo's
|
|
480
|
+
// default branch (main, else master), not whatever the main checkout
|
|
481
|
+
// happens to have checked out - a chat tree cut from a side branch someone
|
|
482
|
+
// left checked out would carry that branch's commits forever. Falls back to
|
|
483
|
+
// HEAD only when neither local branch exists.
|
|
484
|
+
const defaultBranch = await defaultBranchOf(root);
|
|
485
|
+
const baseRev = await git(["rev-parse", defaultBranch ?? "HEAD"], root);
|
|
486
|
+
const base = baseRev.ok ? baseRev.stdout.trim() : (defaultBranch ?? "HEAD");
|
|
487
|
+
const baseLabel = defaultBranch
|
|
488
|
+
? `${defaultBranch} at ${base.slice(0, 7)}`
|
|
489
|
+
: `HEAD at ${base.slice(0, 7)}, no main or master branch`;
|
|
490
|
+
const known = await git(["rev-parse", "--verify", branch], root);
|
|
491
|
+
const add = known.ok
|
|
492
|
+
? await git(["worktree", "add", path, branch], root)
|
|
493
|
+
: await git(["worktree", "add", "-b", branch, path, base], root);
|
|
494
|
+
if (!add.ok) {
|
|
495
|
+
return {
|
|
496
|
+
ok: false,
|
|
497
|
+
reason:
|
|
498
|
+
`The chat worktree ${path} could not be created, so this message did not run and ` +
|
|
499
|
+
`nothing was billed.\ngit said: ${add.detail}`,
|
|
500
|
+
};
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
// Same link a session's tree gets, for the same reason: an agent that
|
|
504
|
+
// cannot run the project's own tests cannot check what it just wrote.
|
|
505
|
+
const modules = join(root, "node_modules");
|
|
506
|
+
if (await exists(modules)) {
|
|
507
|
+
try {
|
|
508
|
+
await symlink(modules, join(path, "node_modules"), nodeModulesLinkType());
|
|
509
|
+
} catch (error) {
|
|
510
|
+
log(`chat worktree ${branch}: node_modules could not be linked: ${error?.message ?? error}`);
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
return {
|
|
515
|
+
ok: true,
|
|
516
|
+
path,
|
|
517
|
+
branch,
|
|
518
|
+
base,
|
|
519
|
+
detail: known.ok
|
|
520
|
+
? `Minted the chat worktree ${path} on the existing branch ${branch}.`
|
|
521
|
+
: `Minted the chat worktree ${path} on branch ${branch} off ${baseLabel}.`,
|
|
522
|
+
};
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
/** Make the worktree a session will run in.
|
|
526
|
+
*
|
|
527
|
+
* Returns `{ cwd, isolated, branch?, path?, detail }`. Never throws and never
|
|
528
|
+
* refuses to run: if this is not a git repo, or git fails for any reason, the
|
|
529
|
+
* session still runs in `root` and `detail` says why it was not isolated. A
|
|
530
|
+
* runner that would rather spawn nothing than spawn unisolated is a runner
|
|
531
|
+
* that stops working the day someone tries it outside a checkout, and the
|
|
532
|
+
* money gate - not this - is what decides whether anything runs at all. */
|
|
533
|
+
async function makeWorktreeImpl(root, id, log = () => {}, title = "") {
|
|
534
|
+
const inRepo = await git(["rev-parse", "--is-inside-work-tree"], root);
|
|
535
|
+
if (!inRepo.ok || inRepo.stdout.trim() !== "true") {
|
|
536
|
+
return {
|
|
537
|
+
cwd: root,
|
|
538
|
+
isolated: false,
|
|
539
|
+
detail:
|
|
540
|
+
`Not a git checkout, so this session ran in the linked folder itself ` +
|
|
541
|
+
`rather than an isolated worktree. Anything it changed, it changed there.`,
|
|
542
|
+
};
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
await ensureIgnored(root, log);
|
|
546
|
+
|
|
547
|
+
const path = worktreePath(root, id, title);
|
|
548
|
+
if (!isContainedWorktree(root, path)) throw new Error("Worktree creation refused: the path leaves this project or traverses a symbolic link.");
|
|
549
|
+
const branch = worktreeBranch(id, title);
|
|
550
|
+
// Branch off the repo's DEFAULT BRANCH (main, else master), not whatever
|
|
551
|
+
// the main checkout happens to have checked out. PROVEN 2026-09-21: basing
|
|
552
|
+
// on HEAD meant a run cut while the checkout sat on a side branch inherited
|
|
553
|
+
// that branch's commits, and "Keep changes" would have dragged the whole
|
|
554
|
+
// thing into main. Falls back to HEAD, and says so, only when neither local
|
|
555
|
+
// branch exists.
|
|
556
|
+
const defaultBranch = await defaultBranchOf(root);
|
|
557
|
+
const baseRev = await git(["rev-parse", defaultBranch ?? "HEAD"], root);
|
|
558
|
+
const base = baseRev.ok ? baseRev.stdout.trim() : (defaultBranch ?? "HEAD");
|
|
559
|
+
const baseLabel = defaultBranch
|
|
560
|
+
? `${defaultBranch} at ${base.slice(0, 7)}`
|
|
561
|
+
: `HEAD at ${base.slice(0, 7)}, no main or master branch`;
|
|
562
|
+
|
|
563
|
+
const add = await git(["worktree", "add", "-b", branch, path, base], root);
|
|
564
|
+
if (!add.ok) {
|
|
565
|
+
return {
|
|
566
|
+
cwd: root,
|
|
567
|
+
isolated: false,
|
|
568
|
+
detail:
|
|
569
|
+
`Could not create a worktree, so this session ran in the linked folder ` +
|
|
570
|
+
`itself and anything it changed, it changed there.\ngit said: ${add.detail}`,
|
|
571
|
+
};
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
// Without this the session cannot run the project's own tests, which is
|
|
575
|
+
// most of what a brief asks it to do before reporting.
|
|
576
|
+
const modules = join(root, "node_modules");
|
|
577
|
+
if (await exists(modules)) {
|
|
578
|
+
try {
|
|
579
|
+
await symlink(modules, join(path, "node_modules"), nodeModulesLinkType());
|
|
580
|
+
} catch (error) {
|
|
581
|
+
log(`[${id}] node_modules could not be linked: ${error?.message ?? error}`);
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
return {
|
|
586
|
+
cwd: path,
|
|
587
|
+
isolated: true,
|
|
588
|
+
branch,
|
|
589
|
+
path,
|
|
590
|
+
detail: `Ran in its own worktree, ${path}, on branch ${branch} off ${baseLabel}.`,
|
|
591
|
+
};
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
/** Take the worktree away IF the session left nothing in it.
|
|
595
|
+
*
|
|
596
|
+
* An untouched tree is litter and goes. A tree with commits or uncommitted
|
|
597
|
+
* changes STAYS, because that is the session's work and the only copy of it -
|
|
598
|
+
* removing it would throw away the thing the run was for. The receipt says
|
|
599
|
+
* which happened, so a kept tree is never a surprise found weeks later. */
|
|
600
|
+
async function cleanWorktreeImpl(root, made, log = () => {}, wroteFiles = []) {
|
|
601
|
+
// A CHAT WORKTREE IS NEVER CLEANED (brief 25, ruling 1, 2026-09-07:
|
|
602
|
+
// one long-lived chat worktree per agent per folder, kept). It is not this
|
|
603
|
+
// run's tree - it was there before the message and is what the NEXT message
|
|
604
|
+
// resumes in, and Claude Code keeps its conversations per working directory,
|
|
605
|
+
// so removing it because one turn happened to leave it tidy would throw the
|
|
606
|
+
// whole conversation away. runner.mjs does not call this for a chat turn at
|
|
607
|
+
// all; this is the guard that says so where the removal is, so a future
|
|
608
|
+
// caller cannot reach it by accident.
|
|
609
|
+
if (isChatWorktreePath(root, made?.path ?? "")) {
|
|
610
|
+
return {
|
|
611
|
+
removed: false,
|
|
612
|
+
detail: `${made?.detail ?? ""}\nKEPT: a chat worktree is the agent's own and outlives every turn that runs in it.`,
|
|
613
|
+
};
|
|
614
|
+
}
|
|
615
|
+
if (!made?.isolated || !made.path) return { removed: false, detail: made?.detail ?? "" };
|
|
616
|
+
if (!(await registeredWorktree(root, made.path))) return { removed: false, detail: "KEPT: worktree ownership could not be verified." };
|
|
617
|
+
|
|
618
|
+
const dirty = await git(["status", "--porcelain"], made.path);
|
|
619
|
+
const base = await git(["merge-base", made.branch, "HEAD"], root);
|
|
620
|
+
const commits = base.ok && base.stdout.trim()
|
|
621
|
+
? await git(["log", "--oneline", `${base.stdout.trim()}..HEAD`], made.path)
|
|
622
|
+
: { ok: false, detail: base.detail || "Could not establish the worktree base." };
|
|
623
|
+
// Failed inspection is not evidence that this tree contains no work.
|
|
624
|
+
if (!dirty.ok || !commits.ok) {
|
|
625
|
+
const detail = `KEPT: could not verify worktree changes: ${!dirty.ok ? dirty.detail : commits.detail}`;
|
|
626
|
+
log(`[${made.branch}] ${detail}`);
|
|
627
|
+
return { removed: false, detail: `${made.detail}\n${detail}` };
|
|
628
|
+
}
|
|
629
|
+
// WHAT THE RUNNER WROTE IS NOT THE SESSION'S WORK. A config dropped in so
|
|
630
|
+
// the engine can reach the board (opencode.json) must not make the tree look
|
|
631
|
+
// dirty, or no opencode worktree would ever be cleaned up.
|
|
632
|
+
//
|
|
633
|
+
// Filtered here rather than excluded in git, and that distinction cost an
|
|
634
|
+
// hour: worktrees SHARE .git/info/exclude with the main checkout, so a
|
|
635
|
+
// "per-worktree" exclude is nothing of the sort - it silently made
|
|
636
|
+
// opencode.json un-trackable across the whole repository, which only
|
|
637
|
+
// surfaced when checking the file in failed. Deciding it at the one place
|
|
638
|
+
// that asks the question touches no shared git config and cannot leak.
|
|
639
|
+
const theirs = dirty.stdout
|
|
640
|
+
.split("\n")
|
|
641
|
+
.map((l) => l.trim())
|
|
642
|
+
.filter((l) => l !== "")
|
|
643
|
+
.filter((l) => !wroteFiles.some((f) => l.endsWith(` ${f}`) || l.endsWith(`/${f}`)));
|
|
644
|
+
const changed = (theirs.length > 0 || commits.stdout.trim() !== "");
|
|
645
|
+
|
|
646
|
+
if (changed) {
|
|
647
|
+
const n = commits.stdout.trim() === "" ? 0 : commits.stdout.trim().split("\n").length;
|
|
648
|
+
return {
|
|
649
|
+
removed: false,
|
|
650
|
+
detail:
|
|
651
|
+
`${made.detail}\nKEPT, because the session left work in it: ` +
|
|
652
|
+
`${n} commit${n === 1 ? "" : "s"}` +
|
|
653
|
+
`${theirs.length > 0 ? " and uncommitted changes" : ""}. ` +
|
|
654
|
+
`Review it there; nothing was merged anywhere.`,
|
|
655
|
+
};
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
const removed = await git(["worktree", "remove", "--force", made.path], root);
|
|
659
|
+
if (!removed.ok) {
|
|
660
|
+
log(`[${made.branch}] worktree could not be removed: ${removed.detail}`);
|
|
661
|
+
return { removed: false, detail: `${made.detail}\nIt is still on disk: ${removed.detail}` };
|
|
662
|
+
}
|
|
663
|
+
await git(["branch", "-D", made.branch], root);
|
|
664
|
+
return { removed: true, detail: `${made.detail}\nIt changed nothing, so the worktree was removed.` };
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
/** Keep spawn worktrees and preview records out of `git status`, without touching a tracked file.
|
|
668
|
+
*
|
|
669
|
+
* They live inside the repo on purpose - every worktree in this project is
|
|
670
|
+
* under `.claude/worktrees/`, and a session's tree should be where he already
|
|
671
|
+
* looks for one rather than in a temp directory he has to be told about. The
|
|
672
|
+
* cost is that git sees them.
|
|
673
|
+
*
|
|
674
|
+
* This clone happens to ignore them already, but through `.git/info/exclude`,
|
|
675
|
+
* which is LOCAL and not checked in - so on a fresh clone, or on anyone
|
|
676
|
+
* else's machine, the first spawn would start dirtying `git status` with its
|
|
677
|
+
* own worktrees. Depending on a file that only exists here is the kind of
|
|
678
|
+
* thing that works right up until it is someone else's problem.
|
|
679
|
+
*
|
|
680
|
+
* `.git/info/exclude` and not `.gitignore`: this is the runner's housekeeping,
|
|
681
|
+
* not a decision about the project, and a detachable sidecar has no business
|
|
682
|
+
* editing a tracked file to make itself comfortable. */
|
|
683
|
+
async function ensureIgnored(root, log = () => {}) {
|
|
684
|
+
const missing = [];
|
|
685
|
+
for (const [file, pattern] of [[".claude/worktrees", ".claude/worktrees/"], [".glytch-server.json", "/.glytch-server.json"], [".glytch-page.json", "/.glytch-page.json"]]) {
|
|
686
|
+
const ignored = await git(["check-ignore", "-q", file], root);
|
|
687
|
+
if (!ignored.ok) missing.push(pattern);
|
|
688
|
+
}
|
|
689
|
+
if (missing.length === 0) return;
|
|
690
|
+
const dir = await git(["rev-parse", "--git-common-dir"], root);
|
|
691
|
+
if (!dir.ok) return;
|
|
692
|
+
const base = dir.stdout.trim();
|
|
693
|
+
// `git rev-parse --git-common-dir` prints an absolute path or a path
|
|
694
|
+
// relative to `root`. `base.startsWith("/")` only recognized the POSIX
|
|
695
|
+
// shape; a Windows absolute path ("C:\Users\..." or "C:/Users/...") never
|
|
696
|
+
// starts with "/", so it was joined onto `root` and wrote the exclude file
|
|
697
|
+
// to a garbage path. `isAbsolute` is platform-bound and gets both shapes
|
|
698
|
+
// right, including on the machine this actually runs on.
|
|
699
|
+
const path = join(isAbsolute(base) ? base : join(root, base), "info", "exclude");
|
|
700
|
+
try {
|
|
701
|
+
let current = "";
|
|
702
|
+
try {
|
|
703
|
+
current = await readFile(path, "utf8");
|
|
704
|
+
} catch {
|
|
705
|
+
/* no exclude file yet, which is normal */
|
|
706
|
+
}
|
|
707
|
+
const additions = missing.filter((pattern) => !current.split(/\r?\n/).includes(pattern));
|
|
708
|
+
if (additions.length === 0) return;
|
|
709
|
+
const sep = current === "" || current.endsWith("\n") ? "" : "\n";
|
|
710
|
+
await writeFile(
|
|
711
|
+
path,
|
|
712
|
+
`${current}${sep}# spawn runner files (bridge/lib/worktree.mjs), local only\n${additions.join("\n")}\n`,
|
|
713
|
+
"utf8",
|
|
714
|
+
);
|
|
715
|
+
} catch (error) {
|
|
716
|
+
// Not fatal: an unignored worktree is untidy, never unsafe.
|
|
717
|
+
log(`could not add runner files to the local exclude: ${error?.message ?? error}`);
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
/** Reuse an existing worktree for a resumed session.
|
|
722
|
+
*
|
|
723
|
+
* Returns the same shape as makeWorktree, but checks the tree still exists on
|
|
724
|
+
* disk and that git still knows about it. A worktree that was manually removed
|
|
725
|
+
* or pruned falls back to null, which tells the caller to make a fresh one. */
|
|
726
|
+
async function reuseWorktreeImpl(root, existingPath, log = () => {}) {
|
|
727
|
+
// Same fence as merge/delete, and for a sharper reason: this path becomes the
|
|
728
|
+
// session's cwd and is reported as `isolated: true`. A resume request naming
|
|
729
|
+
// the linked checkout itself (a real git work tree, so every check below
|
|
730
|
+
// passes) would put an acceptEdits session on his own branch while the
|
|
731
|
+
// receipt swore it was isolated. Refusing here falls back to a fresh tree.
|
|
732
|
+
if (!isContainedWorktree(root, existingPath)) {
|
|
733
|
+
log(`reuse: refusing ${existingPath}, outside .claude/worktrees/`);
|
|
734
|
+
return null;
|
|
735
|
+
}
|
|
736
|
+
if (!existingPath || !(await exists(existingPath))) {
|
|
737
|
+
log(`reuse: path ${existingPath} does not exist on disk`);
|
|
738
|
+
return null;
|
|
739
|
+
}
|
|
740
|
+
if (!(await registeredWorktree(root, existingPath))) return null;
|
|
741
|
+
// Check git still knows this worktree.
|
|
742
|
+
const check = await git(["rev-parse", "--is-inside-work-tree"], existingPath);
|
|
743
|
+
if (!check.ok || check.stdout.trim() !== "true") {
|
|
744
|
+
log(`reuse: ${existingPath} is no longer a git worktree`);
|
|
745
|
+
return null;
|
|
746
|
+
}
|
|
747
|
+
// Find the branch name.
|
|
748
|
+
const branch = await git(["rev-parse", "--abbrev-ref", "HEAD"], existingPath);
|
|
749
|
+
const branchName = branch.ok ? branch.stdout.trim() : "unknown";
|
|
750
|
+
return {
|
|
751
|
+
cwd: existingPath,
|
|
752
|
+
isolated: true,
|
|
753
|
+
branch: branchName,
|
|
754
|
+
path: existingPath,
|
|
755
|
+
reused: true,
|
|
756
|
+
detail: `Reused existing worktree at ${existingPath} on branch ${branchName}.`,
|
|
757
|
+
};
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
/** The commit the worktree started from, for counting what the session added. */
|
|
761
|
+
async function baseOf(made, root) {
|
|
762
|
+
const merge = await git(["merge-base", made.branch, "HEAD"], root);
|
|
763
|
+
return merge.ok && merge.stdout.trim() ? merge.stdout.trim() : "HEAD";
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
// ---------- post-session worktree operations ----------
|
|
767
|
+
//
|
|
768
|
+
// Called by the runner when the app writes a merge or delete signal file.
|
|
769
|
+
// Git operations stay here, in the sidecar process, never in the browser.
|
|
770
|
+
|
|
771
|
+
/** Derive the branch name from a worktree path, if git knows it. */
|
|
772
|
+
export async function branchOfWorktree(worktreePath) {
|
|
773
|
+
if (!worktreePath) return null;
|
|
774
|
+
const branch = await git(["rev-parse", "--abbrev-ref", "HEAD"], worktreePath);
|
|
775
|
+
return branch.ok ? branch.stdout.trim() : null;
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
/** The identity a commit falls back to when the folder has none configured:
|
|
779
|
+
* `git commit` and `commit-tree` refuse outright without one, and a Keep
|
|
780
|
+
* that fails on "Please tell me who you are" is a Keep nobody can press. */
|
|
781
|
+
async function identityArgs(cwd) {
|
|
782
|
+
const email = await git(["config", "user.email"], cwd);
|
|
783
|
+
const name = await git(["config", "user.name"], cwd);
|
|
784
|
+
return [
|
|
785
|
+
...(name.ok && name.stdout.trim() ? [] : ["-c", "user.name=Viewable"]),
|
|
786
|
+
...(email.ok && email.stdout.trim() ? [] : ["-c", "user.email=glytch@localhost"]),
|
|
787
|
+
];
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
/** NUL-separated git output as a list, empties dropped. */
|
|
791
|
+
const zList = (stdout) => stdout.split("\0").filter((p) => p !== "");
|
|
792
|
+
|
|
793
|
+
/** What `git merge-tree --write-tree --name-only` says: the tree it would
|
|
794
|
+
* write, or the files that conflict. Exit 0 is clean and exit 1 is a
|
|
795
|
+
* conflict; anything else means this git cannot answer (merge-tree's
|
|
796
|
+
* --write-tree is git 2.38), which is a refusal, never a guess. */
|
|
797
|
+
async function trialMerge(cwd, ours, theirs) {
|
|
798
|
+
const out = await git(["merge-tree", "--write-tree", "--name-only", ours, theirs], cwd);
|
|
799
|
+
const lines = out.stdout.split("\n");
|
|
800
|
+
if (out.code === 0) return { clean: true, tree: lines[0].trim() };
|
|
801
|
+
if (out.code === 1) {
|
|
802
|
+
const end = lines.indexOf("", 1);
|
|
803
|
+
return { clean: false, files: lines.slice(1, end === -1 ? undefined : end).filter((l) => l !== "") };
|
|
804
|
+
}
|
|
805
|
+
return { error: out.detail || "git merge-tree failed" };
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
/** The paths a merge adds that already have something of the person's on
|
|
809
|
+
* disk: an untracked file, an ignored one, or a file where the merge needs
|
|
810
|
+
* a folder. It reads the disk, not `ls-files --exclude-standard`: git's
|
|
811
|
+
* fast-forward overwrites an ignored file without a word, so an ignored
|
|
812
|
+
* `.env.local` the agent also committed would be lost. A parent the merge
|
|
813
|
+
* itself deletes (`removed`) is the merge's own and is not in the way. */
|
|
814
|
+
async function inTheWay(root, added, removed) {
|
|
815
|
+
const hit = [];
|
|
816
|
+
for (const path of added) {
|
|
817
|
+
const parts = path.split("/");
|
|
818
|
+
for (let i = 1; i <= parts.length; i++) {
|
|
819
|
+
const at = parts.slice(0, i).join("/");
|
|
820
|
+
if (i < parts.length && removed.has(at)) break;
|
|
821
|
+
const found = await lstat(join(root, at)).catch(() => null);
|
|
822
|
+
if (!found) break;
|
|
823
|
+
if (i === parts.length || !found.isDirectory()) {
|
|
824
|
+
hit.push(path);
|
|
825
|
+
break;
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
return hit;
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
/** Keep changes (#483): one gesture that lands a session's copy in the
|
|
833
|
+
* folder and clears the copy away.
|
|
834
|
+
*
|
|
835
|
+
* 1. Commit what the agent left uncommitted in the copy.
|
|
836
|
+
* 2. Work out the merge into the default branch WITHOUT touching the
|
|
837
|
+
* folder (`merge-tree`), and, when the folder is on that branch with
|
|
838
|
+
* changes of the person's own, whether those changes still sit on top
|
|
839
|
+
* of the result. Either one conflicting stops here: nothing in the
|
|
840
|
+
* folder has moved, and the answer names the files (`conflict: true`)
|
|
841
|
+
* so the app can send Coding back to fit its changes around them.
|
|
842
|
+
* 3. Land it. On the default branch: set the person's changes aside (a
|
|
843
|
+
* stash with its own name, tracked files only; untracked files are
|
|
844
|
+
* never touched), fast-forward to the merge commit, put the changes
|
|
845
|
+
* back. On any other branch: move the default branch's ref only, so
|
|
846
|
+
* the folder does not change at all.
|
|
847
|
+
* 4. Delete the copy and its branch.
|
|
848
|
+
*
|
|
849
|
+
* Nothing is pushed: the person pushes when they want to. Returns
|
|
850
|
+
* `{ ok, detail, files?, conflict? }`; a success detail starts "Merged " (the
|
|
851
|
+
* app's `keptOf` reads that). */
|
|
852
|
+
async function mergeWorktreeImpl(root, worktreePath, log = () => {}) {
|
|
853
|
+
if (!isContainedWorktree(root, worktreePath)) {
|
|
854
|
+
log(`refusing to merge a worktree outside .claude/worktrees/: ${worktreePath}`);
|
|
855
|
+
return { ok: false, detail: `Refusing to act on ${worktreePath}: not inside this project's .claude/worktrees/.` };
|
|
856
|
+
}
|
|
857
|
+
if (!(await registeredWorktree(root, worktreePath))) return { ok: false, detail: "Refusing: worktree ownership could not be verified." };
|
|
858
|
+
const branch = await branchOfWorktree(worktreePath);
|
|
859
|
+
if (!branch) {
|
|
860
|
+
return { ok: false, detail: `Could not determine branch for worktree at ${worktreePath}.` };
|
|
861
|
+
}
|
|
862
|
+
// SAME HELPER the worktree's own base used: a `master` repo merges into
|
|
863
|
+
// `master`, not a literal `main` that repo may not even have.
|
|
864
|
+
const defaultBranch = await defaultBranchOf(root);
|
|
865
|
+
if (!defaultBranch) {
|
|
866
|
+
return { ok: false, detail: `Could not find a local main or master branch to merge ${branch} into.` };
|
|
867
|
+
}
|
|
868
|
+
if (branch === defaultBranch) {
|
|
869
|
+
return { ok: false, detail: `Refusing to merge ${branch} into itself.` };
|
|
870
|
+
}
|
|
871
|
+
const folder = basename(root);
|
|
872
|
+
const ident = await identityArgs(root);
|
|
873
|
+
|
|
874
|
+
// 1. THE AGENT'S LEFTOVERS. Exclude the machine-specific preview record
|
|
875
|
+
// even if local exclude setup failed, along with the node_modules link the
|
|
876
|
+
// runner put there: `node_modules/` in a .gitignore matches a directory,
|
|
877
|
+
// not a symlink, so `add -A` alone would commit the link. The preview
|
|
878
|
+
// path uses a glob so git does not reject an already-ignored literal path.
|
|
879
|
+
// Older sessions may already have staged or committed the record. Remove
|
|
880
|
+
// only its index entry, even if the disk copy changed since staging; leave
|
|
881
|
+
// the live preview file intact. Do this before status so a clean tracked
|
|
882
|
+
// record produces a deletion commit too.
|
|
883
|
+
const untrack = await git(["rm", "--cached", "--force", "--ignore-unmatch", "--", ".glytch-server.json", ".glytch-page.json"], worktreePath);
|
|
884
|
+
if (!untrack.ok) return { ok: false, detail: `Could not commit what the session left: ${untrack.detail}` };
|
|
885
|
+
const leftovers = await git(["status", "--porcelain"], worktreePath);
|
|
886
|
+
if (!leftovers.ok) return { ok: false, detail: `Could not read the copy: ${leftovers.detail}` };
|
|
887
|
+
if (leftovers.stdout.trim() !== "") {
|
|
888
|
+
const add = await git(["add", "-A", "--", ".", ":(exclude)node_modules", ":(top,exclude,glob)[.]glytch-server.json", ":(top,exclude,glob)[.]glytch-page.json"], worktreePath);
|
|
889
|
+
const staged = add.ok ? await git(["diff", "--cached", "--quiet"], worktreePath) : add;
|
|
890
|
+
if (!add.ok) return { ok: false, detail: `Could not commit what the session left: ${add.detail}` };
|
|
891
|
+
if (staged.code === 1) {
|
|
892
|
+
const commit = await git([...ident, "commit", "-m", "What the session left uncommitted"], worktreePath);
|
|
893
|
+
if (!commit.ok) return { ok: false, detail: `Could not commit what the session left: ${commit.detail}` };
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
// 2. THE TRIAL, read-only. Every object it writes is unreferenced until
|
|
898
|
+
// step 3 points a ref at it.
|
|
899
|
+
const head = await git(["rev-parse", `refs/heads/${defaultBranch}`], root);
|
|
900
|
+
const tip = await git(["rev-parse", `refs/heads/${branch}`], root);
|
|
901
|
+
if (!head.ok || !tip.ok) return { ok: false, detail: `Could not read ${defaultBranch} or ${branch}: ${head.detail || tip.detail}` };
|
|
902
|
+
const base = head.stdout.trim();
|
|
903
|
+
const trial = await trialMerge(root, base, tip.stdout.trim());
|
|
904
|
+
if (trial.error) return { ok: false, detail: `Keep needs git 2.38 or newer to check for a conflict first. git said: ${trial.error}` };
|
|
905
|
+
const stopped = (files) => ({
|
|
906
|
+
ok: false,
|
|
907
|
+
conflict: true,
|
|
908
|
+
files,
|
|
909
|
+
detail: `Keep stopped: ${folder} changed while Coding worked${files.length ? ` (${files.join(", ")})` : ""}. Nothing in ${folder} was changed.`,
|
|
910
|
+
});
|
|
911
|
+
if (!trial.clean) return stopped(trial.files);
|
|
912
|
+
const made = await git(
|
|
913
|
+
[...ident, "commit-tree", trial.tree, "-p", base, "-p", tip.stdout.trim(), "-m", `Merge branch '${branch}'`],
|
|
914
|
+
root,
|
|
915
|
+
);
|
|
916
|
+
if (!made.ok) return { ok: false, detail: `Could not make the merge commit: ${made.detail}` };
|
|
917
|
+
const merged = made.stdout.trim();
|
|
918
|
+
const changed = await git(["diff", "--name-only", "-z", base, merged], root);
|
|
919
|
+
const files = changed.ok ? zList(changed.stdout).length : 0;
|
|
920
|
+
|
|
921
|
+
const current = await git(["symbolic-ref", "--quiet", "--short", "HEAD"], root);
|
|
922
|
+
const onDefault = current.ok && current.stdout.trim() === defaultBranch;
|
|
923
|
+
|
|
924
|
+
if (onDefault) {
|
|
925
|
+
// The person's untracked and ignored files stay where they are; one the
|
|
926
|
+
// merge would add under the same name is a conflict, not an overwrite.
|
|
927
|
+
// `--no-renames` so a rename or copy counts as its destination added
|
|
928
|
+
// (and a rename's source removed), whatever the folder's diff.renames.
|
|
929
|
+
const added = await git(["diff", "--no-renames", "--name-only", "-z", "--diff-filter=A", base, merged], root);
|
|
930
|
+
const removed = await git(["diff", "--no-renames", "--name-only", "-z", "--diff-filter=D", base, merged], root);
|
|
931
|
+
if (!added.ok || !removed.ok) return { ok: false, detail: `Could not read ${folder}: ${added.detail || removed.detail}` };
|
|
932
|
+
const clash = await inTheWay(root, zList(added.stdout), new Set(zList(removed.stdout)));
|
|
933
|
+
if (clash.length) return stopped(clash);
|
|
934
|
+
// Their tracked changes as a commit nothing points at (`stash create`
|
|
935
|
+
// touches neither the folder nor the stash list), tried on the result.
|
|
936
|
+
const pending = await git(["stash", "create"], root);
|
|
937
|
+
if (!pending.ok) return { ok: false, detail: `Could not read ${folder}: ${pending.detail}` };
|
|
938
|
+
if (pending.stdout.trim()) {
|
|
939
|
+
const fits = await trialMerge(root, merged, pending.stdout.trim());
|
|
940
|
+
if (fits.error) return { ok: false, detail: `Could not check your changes against the merge: ${fits.error}` };
|
|
941
|
+
if (!fits.clean) return stopped(fits.files);
|
|
942
|
+
}
|
|
943
|
+
} else {
|
|
944
|
+
// Another branch (or none) is checked out: the folder is never touched,
|
|
945
|
+
// so the only thing to refuse is a default branch checked out in some
|
|
946
|
+
// other worktree, whose files would fall out of step with its ref.
|
|
947
|
+
const trees = await git(["worktree", "list", "--porcelain"], root);
|
|
948
|
+
if (trees.ok && trees.stdout.split("\n").includes(`branch refs/heads/${defaultBranch}`)) {
|
|
949
|
+
return { ok: false, detail: `${defaultBranch} is open in another copy of this folder, so Keep left it alone.` };
|
|
950
|
+
}
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
// 3. LAND IT. The copy's processes go first: its dev server holds files
|
|
954
|
+
// open and `worktree remove` fails on them.
|
|
955
|
+
await killProcessesIn(worktreePath, log);
|
|
956
|
+
if (onDefault) {
|
|
957
|
+
const landed = await landOnCheckout(root, base, merged, log);
|
|
958
|
+
if (!landed.ok) return landed.conflict ? stopped(landed.files ?? []) : landed;
|
|
959
|
+
} else {
|
|
960
|
+
// Compare-and-swap: a default branch that moved since the trial fails here.
|
|
961
|
+
const moved = await git(["update-ref", `refs/heads/${defaultBranch}`, merged, base], root);
|
|
962
|
+
if (!moved.ok) return stopped([]);
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
// 4. THE COPY GOES. The merge is in; a copy that will not go is said, not hidden.
|
|
966
|
+
const removed = await git(["worktree", "remove", "--force", worktreePath], root);
|
|
967
|
+
const dropped = removed.ok ? await git(["branch", "-D", branch], root) : removed;
|
|
968
|
+
const tail = removed.ok
|
|
969
|
+
? dropped.ok ? " The copy and its branch are gone." : ` The copy is gone; branch ${branch} was kept: ${dropped.detail}`
|
|
970
|
+
: ` The copy was kept: ${removed.detail}`;
|
|
971
|
+
log(`kept ${branch} in ${defaultBranch}: ${files} file(s)`);
|
|
972
|
+
return {
|
|
973
|
+
ok: true,
|
|
974
|
+
files,
|
|
975
|
+
detail: `Merged ${branch} into ${defaultBranch}, ${files} file${files === 1 ? "" : "s"} changed.${tail}`,
|
|
976
|
+
};
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
/** The folder is on the default branch: set the person's tracked changes
|
|
980
|
+
* aside under a name of our own, fast-forward to the merge, put them back.
|
|
981
|
+
* Any step that fails puts everything back as it was. */
|
|
982
|
+
async function landOnCheckout(root, base, merged, log) {
|
|
983
|
+
const tag = `glytch keep ${Date.now()}`;
|
|
984
|
+
const dirty = await git(["status", "--porcelain", "--untracked-files=no"], root);
|
|
985
|
+
let saved = null;
|
|
986
|
+
if (dirty.ok && dirty.stdout.trim() !== "") {
|
|
987
|
+
const push = await git(["stash", "push", "-m", tag], root);
|
|
988
|
+
if (!push.ok) return { ok: false, detail: `Could not set your changes aside: ${push.detail}` };
|
|
989
|
+
const list = await git(["stash", "list", "--format=%H%x09%gs"], root);
|
|
990
|
+
saved = list.stdout.split("\n").map((l) => l.split("\t")).find(([, msg]) => msg?.endsWith(tag))?.[0] ?? null;
|
|
991
|
+
if (!saved) return { ok: false, detail: `Your changes were set aside as "${tag}" in git stash and could not be found again. Nothing was merged.` };
|
|
992
|
+
}
|
|
993
|
+
const restore = async () => {
|
|
994
|
+
if (!saved) return true;
|
|
995
|
+
const back = await git(["stash", "apply", "--index", saved], root);
|
|
996
|
+
const ok = back.ok || (await git(["stash", "apply", saved], root)).ok;
|
|
997
|
+
if (ok) await dropStash(root, saved);
|
|
998
|
+
else log(`could not put the person's changes back; they are in git stash as "${tag}"`);
|
|
999
|
+
return ok;
|
|
1000
|
+
};
|
|
1001
|
+
const ff = await git(["merge", "--ff-only", merged], root);
|
|
1002
|
+
if (!ff.ok) {
|
|
1003
|
+
await restore();
|
|
1004
|
+
return { ok: false, conflict: true, files: [] };
|
|
1005
|
+
}
|
|
1006
|
+
if (!(await restore())) {
|
|
1007
|
+
// The trial said this fits; the folder changed between the trial and now.
|
|
1008
|
+
const unmerged = await git(["diff", "--name-only", "-z", "--diff-filter=U"], root);
|
|
1009
|
+
await git(["reset", "--hard", base], root);
|
|
1010
|
+
const again = await restore();
|
|
1011
|
+
return again
|
|
1012
|
+
? { ok: false, conflict: true, files: unmerged.ok ? zList(unmerged.stdout) : [] }
|
|
1013
|
+
: { ok: false, detail: `Your changes are safe in git stash as "${tag}"; Keep could not put them back by itself.` };
|
|
1014
|
+
}
|
|
1015
|
+
return { ok: true };
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
/** Drop one stash entry by its commit, re-finding its index first: other
|
|
1019
|
+
* stashes may have been pushed above it. */
|
|
1020
|
+
async function dropStash(root, sha) {
|
|
1021
|
+
const list = await git(["stash", "list", "--format=%H"], root);
|
|
1022
|
+
const at = list.stdout.split("\n").indexOf(sha);
|
|
1023
|
+
if (at >= 0) await git(["stash", "drop", `stash@{${at}}`], root);
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
/** Throw away (#483): delete a session's copy and its branch without
|
|
1027
|
+
* merging. The run's history, plan and session stay readable on the board. */
|
|
1028
|
+
async function deleteWorktreeImpl(root, worktreePath, log = () => {}) {
|
|
1029
|
+
if (!isContainedWorktree(root, worktreePath)) {
|
|
1030
|
+
log(`refusing to delete a worktree outside .claude/worktrees/: ${worktreePath}`);
|
|
1031
|
+
return { ok: false, detail: `Refusing to act on ${worktreePath}: not inside this project's .claude/worktrees/.` };
|
|
1032
|
+
}
|
|
1033
|
+
if (!(await registeredWorktree(root, worktreePath))) {
|
|
1034
|
+
return { ok: false, detail: "Refusing: worktree ownership could not be verified." };
|
|
1035
|
+
}
|
|
1036
|
+
const branch = await branchOfWorktree(worktreePath);
|
|
1037
|
+
await killProcessesIn(worktreePath, log);
|
|
1038
|
+
// Recheck after asynchronous process cleanup. A failed Git removal never
|
|
1039
|
+
// authorizes a recursive filesystem deletion.
|
|
1040
|
+
if (!(await registeredWorktree(root, worktreePath))) return { ok: false, detail: "Refusing: worktree ownership changed." };
|
|
1041
|
+
const remove = await git(["worktree", "remove", "--force", worktreePath], root);
|
|
1042
|
+
if (!remove.ok) return { ok: false, detail: `Worktree was kept: ${remove.detail}` };
|
|
1043
|
+
if (branch && branch !== "main" && branch !== "master") {
|
|
1044
|
+
const deleted = await git(["branch", "-D", branch], root);
|
|
1045
|
+
if (!deleted.ok) return { ok: false, detail: `Worktree removed; branch ${branch} was kept: ${deleted.detail}` };
|
|
1046
|
+
}
|
|
1047
|
+
return { ok: true, detail: `Deleted worktree at ${worktreePath}${branch ? ` and branch ${branch}` : ""}.` };
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
/** One summary line for a worktree: branch, commit count, files touched
|
|
1051
|
+
* (committed or not, #482). The ruling on q-mtf66we4: "One summary line: branch, commits, files touched."
|
|
1052
|
+
* Written by the runner into the sidecar so the app can show it on the run's
|
|
1053
|
+
* card before a merge (DATED REWRITE 2026-09-07, brief 26 ruling 4: was "in
|
|
1054
|
+
* the frame"). Returns null when the worktree is not a git checkout. */
|
|
1055
|
+
export async function worktreeSummary(worktreePath, root) {
|
|
1056
|
+
if (!worktreePath) return null;
|
|
1057
|
+
const branch = await branchOfWorktree(worktreePath);
|
|
1058
|
+
if (!branch) return null;
|
|
1059
|
+
const base = await baseOf({ branch }, root);
|
|
1060
|
+
const log = await git(["log", "--oneline", `${base}..HEAD`], worktreePath);
|
|
1061
|
+
const commits = log.ok && log.stdout.trim() !== "" ? log.stdout.trim().split("\n").length : 0;
|
|
1062
|
+
const { files: touched, uncommitted } = await changedPaths(worktreePath, base);
|
|
1063
|
+
const files = touched.length;
|
|
1064
|
+
return {
|
|
1065
|
+
branch, commits, files, uncommitted: uncommitted.length,
|
|
1066
|
+
line: `${branch}: ${commits} commit${commits === 1 ? "" : "s"}, ${files} file${files === 1 ? "" : "s"} touched`,
|
|
1067
|
+
};
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
/** Every path the session changed in its copy, committed or not (#482). A
|
|
1071
|
+
* session stopped before it committed still changed files: they count, so
|
|
1072
|
+
* the ending offers Keep changes, and the merge commits them. */
|
|
1073
|
+
async function changedPaths(worktreePath, base) {
|
|
1074
|
+
const diff = await git(["diff", "--name-only", `${base}..HEAD`], worktreePath);
|
|
1075
|
+
const committed = diff.ok ? diff.stdout.trim().split("\n").filter((l) => l !== "") : [];
|
|
1076
|
+
const uncommitted = await uncommittedFiles(worktreePath);
|
|
1077
|
+
return { files: [...new Set([...committed, ...uncommitted])], uncommitted };
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
/** The paths a run changed in its copy, for the page pictures (#2194). Empty
|
|
1081
|
+
* when the path is not a runner copy. */
|
|
1082
|
+
export async function changedFiles(worktreePath, root) {
|
|
1083
|
+
if (!worktreePath) return [];
|
|
1084
|
+
const branch = await branchOfWorktree(worktreePath);
|
|
1085
|
+
if (!branch) return [];
|
|
1086
|
+
const base = await baseOf({ branch }, root);
|
|
1087
|
+
return (await changedPaths(worktreePath, base)).files;
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
/** The session's changes not yet committed in its copy, as paths. The
|
|
1091
|
+
* node_modules link the runner made (makeWorktree) is not the session's. */
|
|
1092
|
+
async function uncommittedFiles(worktreePath) {
|
|
1093
|
+
const status = await git(["status", "--porcelain", "-z", "--untracked-files=all"], worktreePath);
|
|
1094
|
+
if (!status.ok) return [];
|
|
1095
|
+
const parts = status.stdout.split("\0");
|
|
1096
|
+
const paths = [];
|
|
1097
|
+
for (let i = 0; i < parts.length; i++) {
|
|
1098
|
+
const entry = parts[i];
|
|
1099
|
+
if (entry.length < 4) continue;
|
|
1100
|
+
paths.push(entry.slice(3));
|
|
1101
|
+
// A rename or copy is followed by its old path, which is not a second file.
|
|
1102
|
+
if (entry[0] === "R" || entry[0] === "C") i++;
|
|
1103
|
+
}
|
|
1104
|
+
return paths.filter((p) => p !== "node_modules");
|
|
1105
|
+
}
|
|
1106
|
+
|
|
1107
|
+
// LOCKED WRAPPERS for the five mutation functions. Git mutations on the same
|
|
1108
|
+
// root run one at a time, serialized by the in-process lock. Two concurrent
|
|
1109
|
+
// mergeWorktree calls on the same root are guaranteed to run sequentially, order
|
|
1110
|
+
// preserved. Reads (branchOfWorktree, worktreeSummary)
|
|
1111
|
+
// stay unlocked.
|
|
1112
|
+
export async function makeWorktree(root, id, log = () => {}, title = "") {
|
|
1113
|
+
return withRepoLock(root, () => makeWorktreeImpl(root, id, log, title), log);
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
/** The chat tree's mint takes the repo lock like every other git mutation:
|
|
1117
|
+
* two rooms' first messages to the same agent, typed at once, are two
|
|
1118
|
+
* `worktree add` calls for one path. */
|
|
1119
|
+
export async function makeChatWorktree(root, path, log = () => {}) {
|
|
1120
|
+
return withRepoLock(root, () => makeChatWorktreeImpl(root, path, log), log);
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1123
|
+
export async function cleanWorktree(root, made, log = () => {}, wroteFiles = []) {
|
|
1124
|
+
return withRepoLock(root, () => cleanWorktreeImpl(root, made, log, wroteFiles), log);
|
|
1125
|
+
}
|
|
1126
|
+
|
|
1127
|
+
export async function reuseWorktree(root, existingPath, log = () => {}) {
|
|
1128
|
+
return withRepoLock(root, () => reuseWorktreeImpl(root, existingPath, log), log);
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1131
|
+
export async function mergeWorktree(root, worktreePath, log = () => {}) {
|
|
1132
|
+
return withRepoLock(root, () => mergeWorktreeImpl(root, worktreePath, log), log);
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
export async function deleteWorktree(root, worktreePath, log = () => {}) {
|
|
1136
|
+
return withRepoLock(root, () => deleteWorktreeImpl(root, worktreePath, log), log);
|
|
1137
|
+
}
|
|
1138
|
+
|