claude-code-session-manager 0.72.0 → 0.73.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/dist/assets/{AgentLibrary-CXw7Z5Iw.js → AgentLibrary-BowrgaXK.js} +1 -1
- package/dist/assets/{History-D64U_aDf.js → History-B8rJXcxk.js} +1 -1
- package/dist/assets/{Hooks-BFVkM6QH.js → Hooks-D-KzXjDk.js} +1 -1
- package/dist/assets/{HostBilko-BRERoCra.js → HostBilko-BSJhBE9I.js} +1 -1
- package/dist/assets/{Library-C6shVsjv.js → Library-CtqgqUc6.js} +1 -1
- package/dist/assets/{ListDetail-dXWSZuDV.js → ListDetail-D7k4TXvO.js} +1 -1
- package/dist/assets/{MarkdownEditor-D_akyPJ2.js → MarkdownEditor-DFm0I9B_.js} +1 -1
- package/dist/assets/{McpServers-DxROkYGa.js → McpServers-DDK04FMq.js} +1 -1
- package/dist/assets/{Memory-OqUyTRrr.js → Memory-B1p59Mas.js} +1 -1
- package/dist/assets/{Panel-Biw98xRO.js → Panel-BJpqcDzp.js} +1 -1
- package/dist/assets/{Permissions-CIdZOzK8.js → Permissions-CFC3RusZ.js} +1 -1
- package/dist/assets/{Plugins-Dt147wEE.js → Plugins-TeCQMr6X.js} +2 -2
- package/dist/assets/{ProvenanceBadge-BUikIHf4.js → ProvenanceBadge-BfIzSRrY.js} +1 -1
- package/dist/assets/{SaveBar-BoHRDHlp.js → SaveBar-7SmNcZB-.js} +1 -1
- package/dist/assets/{Scheduler-CipmAcy7.js → Scheduler-B9ovEniK.js} +1 -1
- package/dist/assets/{ScopeSwitcher-CkCwUYU5.js → ScopeSwitcher-KmemfTLa.js} +1 -1
- package/dist/assets/Settings-CxZijd66.js +3 -0
- package/dist/assets/{SkillReferenceGraph-BuSuA3B7.js → SkillReferenceGraph-BhACzgW7.js} +1 -1
- package/dist/assets/{Skills-BM8w5BGV.js → Skills-CFZU6ltE.js} +1 -1
- package/dist/assets/{SystemPrompt-BWOdtfze.js → SystemPrompt-3tHpyoge.js} +1 -1
- package/dist/assets/{TagLibrary-cn3JECC5.js → TagLibrary-BoJHe-aY.js} +1 -1
- package/dist/assets/{TiptapBody-BwoXKY8J.js → TiptapBody-DzlxVXaw.js} +1 -1
- package/dist/assets/{Toggle-BlNVxkq-.js → Toggle-DKmoif0X.js} +1 -1
- package/dist/assets/{index-BpzvONtX.js → index-Dtv0whpT.js} +672 -672
- package/dist/assets/{index-DDpA9yBe.css → index-N_v_4fqC.css} +1 -1
- package/dist/assets/{settingsSchema-DePBZnaw.js → settingsSchema-CW-0QfXi.js} +1 -1
- package/dist/index.html +2 -2
- package/package.json +1 -1
- package/scripts/lib/activeSessions.cjs +10 -1
- package/src/main/__tests__/chatRunner-epic-worktree-execcwd.test.cjs +125 -0
- package/src/main/__tests__/pty-epic-worktree-spawn-cwd.test.cjs +156 -0
- package/src/main/__tests__/queueOpsArchiveDestination.test.cjs +65 -0
- package/src/main/chatRunner.cjs +11 -1
- package/src/main/index.cjs +6 -0
- package/src/main/ipcSchemas.cjs +35 -0
- package/src/main/lib/__tests__/epicSpawnCwd.test.cjs +193 -0
- package/src/main/lib/__tests__/epicWorktreeBoot.test.cjs +136 -0
- package/src/main/lib/__tests__/epicWorktreeMerge.test.cjs +130 -0
- package/src/main/lib/__tests__/epicWorktreeMint.test.cjs +115 -0
- package/src/main/lib/__tests__/epicWorktreeProjectConfig.test.cjs +79 -0
- package/src/main/lib/__tests__/gitWorktree.test.cjs +400 -0
- package/src/main/lib/__tests__/opsRootAbsoluteCwd.test.cjs +126 -0
- package/src/main/lib/__tests__/terminalRunOutcome.test.cjs +82 -0
- package/src/main/lib/epicSpawnCwd.cjs +95 -0
- package/src/main/lib/epicWorktreeBoot.cjs +49 -0
- package/src/main/lib/epicWorktreeMerge.cjs +63 -0
- package/src/main/lib/epicWorktreeMint.cjs +54 -0
- package/src/main/lib/epicWorktreeProjectConfig.cjs +102 -0
- package/src/main/lib/gitWorktree.cjs +446 -0
- package/src/main/lib/jobWorktree.cjs +36 -293
- package/src/main/lib/promptSessionSchema.cjs +15 -0
- package/src/main/lib/queueStore.cjs +18 -0
- package/src/main/lib/terminalRunOutcome.cjs +14 -2
- package/src/main/pty.cjs +11 -1
- package/src/main/queueOps.cjs +40 -12
- package/src/main/scheduler.cjs +14 -0
- package/src/preload/api.d.ts +59 -0
- package/src/preload/index.cjs +22 -0
- package/dist/assets/Settings-CmU_7z2e.js +0 -3
|
@@ -0,0 +1,446 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* gitWorktree.cjs — generalized `git worktree` isolation primitives (PRD
|
|
3
|
+
* 1032), backing BOTH scheduler jobs (`sm-job/<slug>`, unchanged behavior —
|
|
4
|
+
* see jobWorktree.cjs, now a thin wrapper over this module) and interactive
|
|
5
|
+
* Epic sessions (`sm-epic/<epicId>`, new).
|
|
6
|
+
*
|
|
7
|
+
* Every entry point below takes an explicit `kind: 'job' | 'epic'` (or is
|
|
8
|
+
* itself kind-scoped via a `create*Worktree`/`integrate*Branch`/
|
|
9
|
+
* `cleanup*Worktree` wrapper) that selects: the branch prefix, the on-disk
|
|
10
|
+
* worktree root subfolder, the disable/cap env vars, and the concurrency cap
|
|
11
|
+
* default. Everything else — the git plumbing, the never-throws contract, the
|
|
12
|
+
* ff-only→merge-commit→abort-and-flag integration strategy, the TOCTOU-safe
|
|
13
|
+
* synchronous slot reservation — is one shared implementation.
|
|
14
|
+
*
|
|
15
|
+
* ---------- the ops-root hazard (read before touching cwd plumbing) ----------
|
|
16
|
+
*
|
|
17
|
+
* `<cwd>/session-manager-operations/` (PRDs, queue state, run logs, the
|
|
18
|
+
* active-index.json Epic store) is resolved from a project's cwd by
|
|
19
|
+
* lib/prdLocations.cjs, lib/queueStore.cjs, and every prompt-sessions IPC
|
|
20
|
+
* handler — all of them take `cwd` as an explicit parameter, never
|
|
21
|
+
* `process.cwd()`. That means the ops root follows WHATEVER cwd value the
|
|
22
|
+
* caller passes in. A prior incident (recorded in this project's memory as
|
|
23
|
+
* `no_schedule_self_e2e`) is exactly this mistake: swapping a job's cwd for a
|
|
24
|
+
* worktree cwd gives it a different, EMPTY ops root and orphans its own
|
|
25
|
+
* PRD/queue row.
|
|
26
|
+
*
|
|
27
|
+
* This invariant carries forward UNCHANGED into the Epic path: this module
|
|
28
|
+
* never changes an Epic's owning-project `cwd` either. It only ever hands
|
|
29
|
+
* back a SEPARATE `dir` (the worktree directory) for a caller to use as a
|
|
30
|
+
* spawned process's `cwd` *spawn option* — every PRD-path resolution, queue
|
|
31
|
+
* read/write, active-index.json read/write, and run-log path must keep using
|
|
32
|
+
* the real project `cwd` exactly as before. The next PRD in this chain (which
|
|
33
|
+
* wires an Epic's PTY/Chat spawn cwd to its worktree) must preserve this same
|
|
34
|
+
* split: spawn cwd = worktree dir, ops-root cwd = real project cwd, always.
|
|
35
|
+
*
|
|
36
|
+
* ---------- commit-guard visibility across the worktree boundary ----------
|
|
37
|
+
*
|
|
38
|
+
* For the job kind, scheduler.cjs's commit-guard reads git state
|
|
39
|
+
* (uncommittedChanges/gitHead) from the main tree before and after a run. If
|
|
40
|
+
* a job's commit only ever lands on a throwaway worktree branch, the main
|
|
41
|
+
* tree's HEAD never moves and the guard would wrongly conclude nothing was
|
|
42
|
+
* committed (the pre-existing `pass-no-commit-worktree-commit-invisible-at-
|
|
43
|
+
* exit` / RCA 770-pr269 incident class). `integrateBranch` closes that gap
|
|
44
|
+
* for OUR managed worktrees: it merges the branch into `cwd`'s own HEAD
|
|
45
|
+
* (ff-only when possible, a real merge commit otherwise) BEFORE any post-run
|
|
46
|
+
* git check relying on the main tree's HEAD — so the commit is real on the
|
|
47
|
+
* main tree, not something a guard has to go looking for.
|
|
48
|
+
*/
|
|
49
|
+
'use strict';
|
|
50
|
+
|
|
51
|
+
const fs = require('node:fs');
|
|
52
|
+
const fsp = require('node:fs/promises');
|
|
53
|
+
const path = require('node:path');
|
|
54
|
+
const os = require('node:os');
|
|
55
|
+
const crypto = require('node:crypto');
|
|
56
|
+
const { execFile } = require('node:child_process');
|
|
57
|
+
|
|
58
|
+
// Per-kind configuration. Roots are kept OUTSIDE any project's own tree
|
|
59
|
+
// (os.tmpdir(), not `<cwd>/.git/...`) so a managed worktree never shows up in
|
|
60
|
+
// the main tree's own file listings, `find`, or a tsc rootDir scan of `<cwd>`.
|
|
61
|
+
//
|
|
62
|
+
// Job default cap (4): disk estimate for this repo (session-manager) — `git
|
|
63
|
+
// worktree add` checks out only git-tracked source, not `node_modules`/
|
|
64
|
+
// `dist` — a fresh checkout of this repo's tracked tree is ~30-40 MB
|
|
65
|
+
// (measured via `git ls-files | xargs du -ch`, minus the shared .git object
|
|
66
|
+
// store). At 4 concurrent worktrees that's under 200 MB — negligible next to
|
|
67
|
+
// whatever a job's own build step may add inside its worktree (uncounted
|
|
68
|
+
// here; bounded by scheduler.cjs's own memory gate, not by this cap).
|
|
69
|
+
//
|
|
70
|
+
// Epic default cap (50, effectively unbounded in practice): a scheduler job
|
|
71
|
+
// runs minutes and is torn down the moment its PRD completes, so a tight cap
|
|
72
|
+
// plus a fallback-to-in-place is the right trade — worst case, one job waits
|
|
73
|
+
// a few minutes for a slot to free up. An Epic is the opposite shape: it runs
|
|
74
|
+
// for hours or days across an interactive human session, and "fall back to
|
|
75
|
+
// running in the main tree" for an Epic means silently losing the isolation
|
|
76
|
+
// the whole feature exists to provide, for as long as that Epic stays open.
|
|
77
|
+
// The job cap's assumptions (few, short-lived, quickly-reclaimed checkouts)
|
|
78
|
+
// simply don't transfer, so the epic kind gets a separately-configurable,
|
|
79
|
+
// much higher ceiling rather than sharing the job kind's default.
|
|
80
|
+
const KIND_CONFIG = {
|
|
81
|
+
job: {
|
|
82
|
+
root: path.join(os.tmpdir(), 'session-manager-job-worktrees'),
|
|
83
|
+
branchPrefix: 'sm-job/',
|
|
84
|
+
disableEnv: 'SM_JOB_WORKTREE_DISABLE',
|
|
85
|
+
maxEnv: 'SM_JOB_WORKTREE_MAX',
|
|
86
|
+
defaultMax: 4,
|
|
87
|
+
},
|
|
88
|
+
epic: {
|
|
89
|
+
root: path.join(os.tmpdir(), 'session-manager-epic-worktrees'),
|
|
90
|
+
branchPrefix: 'sm-epic/',
|
|
91
|
+
disableEnv: 'SM_EPIC_WORKTREE_DISABLE',
|
|
92
|
+
maxEnv: 'SM_EPIC_WORKTREE_MAX',
|
|
93
|
+
defaultMax: 50,
|
|
94
|
+
},
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
function configFor(kind) {
|
|
98
|
+
const cfg = KIND_CONFIG[kind];
|
|
99
|
+
if (!cfg) throw new Error(`gitWorktree: unknown kind "${kind}" (expected 'job' or 'epic')`);
|
|
100
|
+
return cfg;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function worktreeRootFor(kind) {
|
|
104
|
+
return configFor(kind).root;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* `cwd` is optional (job-kind callers never pass it — the per-project toggle
|
|
109
|
+
* only exists for the epic kind, PRD 1035) and, when passed, additionally
|
|
110
|
+
* consults epicWorktreeProjectConfig.cjs's per-project UI toggle — the
|
|
111
|
+
* env var still wins machine-wide regardless of what any project has set.
|
|
112
|
+
* Lazily required to avoid a load-order cycle (neither module needs the
|
|
113
|
+
* other at require time, only at call time).
|
|
114
|
+
*/
|
|
115
|
+
function isWorktreeDisabled(kind, cwd) {
|
|
116
|
+
if (process.env[configFor(kind).disableEnv] === '1') return true;
|
|
117
|
+
if (kind === 'epic' && cwd) {
|
|
118
|
+
const { isEpicWorktreeDisabledForProject } = require('./epicWorktreeProjectConfig.cjs');
|
|
119
|
+
if (isEpicWorktreeDisabledForProject(cwd)) return true;
|
|
120
|
+
}
|
|
121
|
+
return false;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function getMaxConcurrentWorktrees(kind) {
|
|
125
|
+
const cfg = configFor(kind);
|
|
126
|
+
const raw = Number(process.env[cfg.maxEnv]);
|
|
127
|
+
return Number.isFinite(raw) && raw > 0 ? Math.floor(raw) : cfg.defaultMax;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// In-memory count of worktrees currently checked out by THIS process, kept
|
|
131
|
+
// PER KIND so a burst of Epic worktrees can never starve the job cap (or vice
|
|
132
|
+
// versa). Reset to 0 per kind on every restart by design — a crash can never
|
|
133
|
+
// leave either counter permanently wedged above its cap; reconcileWorktreesOnBoot
|
|
134
|
+
// cleans up any leaked ON-DISK checkouts separately (see below).
|
|
135
|
+
const activeWorktreeCount = { job: 0, epic: 0 };
|
|
136
|
+
|
|
137
|
+
function execGit(args, { cwd, timeout = 20_000 } = {}) {
|
|
138
|
+
return new Promise((resolve, reject) => {
|
|
139
|
+
execFile('git', args, { cwd, timeout, windowsHide: true, encoding: 'utf8' }, (err, stdout, stderr) => {
|
|
140
|
+
if (err) {
|
|
141
|
+
err.stderrText = stderr;
|
|
142
|
+
reject(err);
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
resolve(stdout || '');
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
async function isGitRepo(cwd) {
|
|
151
|
+
if (!cwd) return false;
|
|
152
|
+
try {
|
|
153
|
+
const out = await execGit(['rev-parse', '--is-inside-work-tree'], { cwd, timeout: 10_000 });
|
|
154
|
+
return out.trim() === 'true';
|
|
155
|
+
} catch {
|
|
156
|
+
return false;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** True when `cwd`'s own working tree (not any worktree) has zero pending changes. */
|
|
161
|
+
async function isBaseTreeClean(cwd) {
|
|
162
|
+
try {
|
|
163
|
+
const out = await execGit(['status', '--porcelain'], { cwd, timeout: 10_000 });
|
|
164
|
+
return out.trim().length === 0;
|
|
165
|
+
} catch {
|
|
166
|
+
return false;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function hashOf(input) {
|
|
171
|
+
return crypto.createHash('sha1').update(input).digest('hex').slice(0, 16);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function worktreeDirFor(kind, cwd, key) {
|
|
175
|
+
return path.join(worktreeRootFor(kind), hashOf(cwd), key);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function branchNameFor(kind, key) {
|
|
179
|
+
return `${configFor(kind).branchPrefix}${key}`;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** Best-effort teardown of one worktree checkout — never throws. */
|
|
183
|
+
async function removeWorktreeDir(cwd, dir) {
|
|
184
|
+
try {
|
|
185
|
+
await execGit(['worktree', 'remove', '--force', dir], { cwd, timeout: 15_000 });
|
|
186
|
+
} catch {
|
|
187
|
+
// Not registered (already removed) or dir already gone — fall through to
|
|
188
|
+
// a plain rm so a half-created checkout never leaks disk either.
|
|
189
|
+
}
|
|
190
|
+
try {
|
|
191
|
+
await fsp.rm(dir, { recursive: true, force: true });
|
|
192
|
+
} catch {
|
|
193
|
+
/* best-effort */
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Create a linked worktree on a fresh branch checked out from the main
|
|
199
|
+
* tree's current HEAD. Returns `{ ok: true, dir, branch, baseCwd }` on
|
|
200
|
+
* success, or `{ ok: false, reason }` — the reason is always a short,
|
|
201
|
+
* human-readable string meant to be logged verbatim so a fallback to running
|
|
202
|
+
* in place is never silent.
|
|
203
|
+
*
|
|
204
|
+
* Never throws: every failure mode (not a repo, dirty base, cap reached, git
|
|
205
|
+
* error) is a normal, expected outcome for a project that hasn't opted into
|
|
206
|
+
* — or currently can't support — isolation, not an exceptional one.
|
|
207
|
+
*/
|
|
208
|
+
async function createWorktree({ kind, cwd, key }) {
|
|
209
|
+
configFor(kind); // throws on an unknown kind before anything else runs
|
|
210
|
+
if (!cwd || typeof cwd !== 'string') return { ok: false, reason: 'no cwd provided' };
|
|
211
|
+
if (!key || typeof key !== 'string') return { ok: false, reason: 'no key provided' };
|
|
212
|
+
if (isWorktreeDisabled(kind, cwd)) return { ok: false, reason: `disabled via ${configFor(kind).disableEnv}=1 or the project's isolation toggle` };
|
|
213
|
+
|
|
214
|
+
if (!(await isGitRepo(cwd))) return { ok: false, reason: 'not a git repository' };
|
|
215
|
+
|
|
216
|
+
// A dirty base tree means the caller may be depending on the human's own
|
|
217
|
+
// uncommitted WIP in `cwd` — a worktree only ever checks out committed
|
|
218
|
+
// HEAD content, so isolating into one here would silently drop that WIP
|
|
219
|
+
// from what it sees. Falling back to running in place is strictly safer
|
|
220
|
+
// than guessing.
|
|
221
|
+
if (!(await isBaseTreeClean(cwd))) return { ok: false, reason: 'base working tree has uncommitted changes' };
|
|
222
|
+
|
|
223
|
+
if (activeWorktreeCount[kind] >= getMaxConcurrentWorktrees(kind)) {
|
|
224
|
+
return { ok: false, reason: `worktree cap reached (${getMaxConcurrentWorktrees(kind)} concurrent)` };
|
|
225
|
+
}
|
|
226
|
+
// Reserve the slot SYNCHRONOUSLY (before any `await` below) so two callers
|
|
227
|
+
// invoked in the same tick can't both pass the check above and both
|
|
228
|
+
// proceed — without this, the cap is a TOCTOU race: N concurrent callers
|
|
229
|
+
// all read the pre-increment count before either increments it. Released
|
|
230
|
+
// again below on any failure path so a failed create never permanently
|
|
231
|
+
// shrinks capacity.
|
|
232
|
+
activeWorktreeCount[kind]++;
|
|
233
|
+
|
|
234
|
+
const dir = worktreeDirFor(kind, cwd, key);
|
|
235
|
+
const branch = branchNameFor(kind, key);
|
|
236
|
+
try {
|
|
237
|
+
await fsp.mkdir(path.dirname(dir), { recursive: true });
|
|
238
|
+
// Defensive: a same-key leftover from a prior crashed run (same slug/id
|
|
239
|
+
// can legitimately re-fire after a transient failure) must not collide
|
|
240
|
+
// with `git worktree add`'s own branch/path checks.
|
|
241
|
+
await removeWorktreeDir(cwd, dir);
|
|
242
|
+
try { await execGit(['branch', '-D', branch], { cwd, timeout: 10_000 }); } catch { /* didn't exist */ }
|
|
243
|
+
await execGit(['worktree', 'add', '-b', branch, dir, 'HEAD'], { cwd, timeout: 30_000 });
|
|
244
|
+
} catch (e) {
|
|
245
|
+
activeWorktreeCount[kind] = Math.max(0, activeWorktreeCount[kind] - 1);
|
|
246
|
+
return { ok: false, reason: `git worktree add failed: ${(e && (e.stderrText || e.message)) || e}` };
|
|
247
|
+
}
|
|
248
|
+
return { ok: true, dir, branch, baseCwd: cwd };
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Integrate a branch back into `cwd`'s current HEAD — fast-forward when
|
|
253
|
+
* possible, a real merge commit when the main tree advanced underneath (a
|
|
254
|
+
* sibling job/Epic merged first) since the worktree was created. Returns
|
|
255
|
+
* `{ ok: true, integrated: boolean, ...}` on success (integrated:false means
|
|
256
|
+
* the branch had no new commits — a legitimate no-op, not a failure), or
|
|
257
|
+
* `{ ok: false, reason }` when neither ff-only nor a real merge could land —
|
|
258
|
+
* e.g. a genuine content conflict. On failure the branch is left un-merged
|
|
259
|
+
* and NOT deleted (see cleanupWorktree) so the work is recoverable, never
|
|
260
|
+
* silently discarded.
|
|
261
|
+
*/
|
|
262
|
+
async function integrateBranch({ cwd, branch, key, kind }) {
|
|
263
|
+
if (!cwd || !branch) return { ok: false, reason: 'missing cwd/branch' };
|
|
264
|
+
let branchHead;
|
|
265
|
+
try {
|
|
266
|
+
branchHead = (await execGit(['rev-parse', branch], { cwd, timeout: 10_000 })).trim();
|
|
267
|
+
} catch (e) {
|
|
268
|
+
return { ok: false, reason: `branch ${branch} not found: ${(e && (e.stderrText || e.message)) || e}` };
|
|
269
|
+
}
|
|
270
|
+
let mergeBase = '';
|
|
271
|
+
try {
|
|
272
|
+
mergeBase = (await execGit(['merge-base', 'HEAD', branch], { cwd, timeout: 10_000 })).trim();
|
|
273
|
+
} catch {
|
|
274
|
+
mergeBase = '';
|
|
275
|
+
}
|
|
276
|
+
if (mergeBase && mergeBase === branchHead) {
|
|
277
|
+
return { ok: true, integrated: false, reason: 'branch has no new commits' };
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
try {
|
|
281
|
+
await execGit(['merge', '--ff-only', branch], { cwd, timeout: 30_000 });
|
|
282
|
+
return { ok: true, integrated: true, fastForward: true };
|
|
283
|
+
} catch {
|
|
284
|
+
// Main tree advanced since the worktree branched (a sibling job/Epic
|
|
285
|
+
// merged first) — a real merge commit still lands the branch's commit(s).
|
|
286
|
+
}
|
|
287
|
+
const mergeMessage = kind === 'epic'
|
|
288
|
+
? `merge epic ${key || branch}`
|
|
289
|
+
// Preserves the exact job-kind message the pre-generalization jobWorktree.cjs
|
|
290
|
+
// used, so a job's merge-commit text never changes under this refactor.
|
|
291
|
+
: `merge scheduler job ${key || branch}`;
|
|
292
|
+
try {
|
|
293
|
+
await execGit(['merge', '--no-ff', '--no-edit', '-m', mergeMessage, branch], { cwd, timeout: 30_000 });
|
|
294
|
+
return { ok: true, integrated: true, mergeCommit: true };
|
|
295
|
+
} catch (e) {
|
|
296
|
+
// Abort a half-applied merge so `cwd` isn't left in a mid-merge state.
|
|
297
|
+
try { await execGit(['merge', '--abort'], { cwd, timeout: 10_000 }); } catch { /* nothing to abort */ }
|
|
298
|
+
return { ok: false, reason: `merge failed (likely a real content conflict): ${(e && (e.stderrText || e.message)) || e}` };
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* Tear down one worktree checkout after it has been integrated (or failed to
|
|
304
|
+
* integrate). Always removes the linked-worktree checkout (freeing its
|
|
305
|
+
* disk); only deletes the branch ref when `keepBranch` is falsy — a failed
|
|
306
|
+
* integration keeps the branch around for manual recovery per
|
|
307
|
+
* integrateBranch's contract above. Never throws.
|
|
308
|
+
*/
|
|
309
|
+
async function cleanupWorktree({ kind, cwd, dir, branch, keepBranch }) {
|
|
310
|
+
configFor(kind); // throws on an unknown kind before anything else runs
|
|
311
|
+
if (dir) await removeWorktreeDir(cwd, dir);
|
|
312
|
+
if (branch && !keepBranch) {
|
|
313
|
+
try { await execGit(['branch', '-D', branch], { cwd, timeout: 10_000 }); } catch { /* already gone */ }
|
|
314
|
+
}
|
|
315
|
+
try { await execGit(['worktree', 'prune'], { cwd, timeout: 10_000 }); } catch { /* best effort */ }
|
|
316
|
+
activeWorktreeCount[kind] = Math.max(0, activeWorktreeCount[kind] - 1);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/** Parse `git worktree list --porcelain` into `[{ worktree, branch }]`. */
|
|
320
|
+
function parseWorktreeListPorcelain(text) {
|
|
321
|
+
const entries = [];
|
|
322
|
+
let cur = null;
|
|
323
|
+
for (const line of String(text || '').split('\n')) {
|
|
324
|
+
if (line.startsWith('worktree ')) {
|
|
325
|
+
cur = { worktree: line.slice('worktree '.length).trim(), branch: null };
|
|
326
|
+
entries.push(cur);
|
|
327
|
+
} else if (line.startsWith('branch ') && cur) {
|
|
328
|
+
cur.branch = line.slice('branch '.length).trim().replace(/^refs\/heads\//, '');
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
return entries;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/** Strips a kind's branch prefix off a branch name, returning the bare
|
|
335
|
+
* key (slug/epicId), or null if the branch doesn't carry that prefix. */
|
|
336
|
+
function keyFromBranch(kind, branch) {
|
|
337
|
+
const prefix = configFor(kind).branchPrefix;
|
|
338
|
+
if (!branch || !branch.startsWith(prefix)) return null;
|
|
339
|
+
return branch.slice(prefix.length);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* Boot reconciliation: a worktree that survives a process crash (app killed
|
|
344
|
+
* mid-run, host reboot) leaks disk and a dangling branch forever unless
|
|
345
|
+
* something cleans it up — this is that something. For each known project
|
|
346
|
+
* cwd, lists every registered worktree, forcibly removes any that live under
|
|
347
|
+
* this kind's own root (never touches a worktree a human created for their
|
|
348
|
+
* own purposes, or one belonging to the OTHER kind), deletes its branch, and
|
|
349
|
+
* prunes stale registrations.
|
|
350
|
+
*
|
|
351
|
+
* `opts.isLive(key, entry)` is an optional liveness predicate — when it
|
|
352
|
+
* returns true for a given worktree's key (the slug/epicId parsed off its
|
|
353
|
+
* branch name), that worktree is left alone instead of being reaped. This is
|
|
354
|
+
* what lets the epic kind skip a worktree whose owning Epic is still
|
|
355
|
+
* `active` in the project's active-index.json (the caller supplies that
|
|
356
|
+
* check; this module has no active-index.json knowledge of its own — see
|
|
357
|
+
* this chain's next PRD for the real wiring). Job-kind reconciliation has no
|
|
358
|
+
* equivalent concept (a job worktree found at boot is, by definition, from a
|
|
359
|
+
* run that didn't finish) and typically omits `isLive`.
|
|
360
|
+
*
|
|
361
|
+
* Never throws — a project that isn't a git repo, or has no worktrees, is a
|
|
362
|
+
* silent no-op.
|
|
363
|
+
*/
|
|
364
|
+
async function reconcileWorktreesOnBoot(cwds, opts = {}) {
|
|
365
|
+
const kind = opts.kind || 'job';
|
|
366
|
+
const isLive = typeof opts.isLive === 'function' ? opts.isLive : null;
|
|
367
|
+
const root = worktreeRootFor(kind);
|
|
368
|
+
const list = Array.isArray(cwds) ? cwds.filter(Boolean) : [];
|
|
369
|
+
for (const cwd of list) {
|
|
370
|
+
if (!(await isGitRepo(cwd))) continue;
|
|
371
|
+
let out = '';
|
|
372
|
+
try {
|
|
373
|
+
out = await execGit(['worktree', 'list', '--porcelain'], { cwd, timeout: 15_000 });
|
|
374
|
+
} catch {
|
|
375
|
+
continue;
|
|
376
|
+
}
|
|
377
|
+
const entries = parseWorktreeListPorcelain(out);
|
|
378
|
+
for (const entry of entries) {
|
|
379
|
+
if (!entry.worktree || !entry.worktree.startsWith(root + path.sep)) continue;
|
|
380
|
+
const key = keyFromBranch(kind, entry.branch);
|
|
381
|
+
if (isLive && key && (await isLive(key, entry))) continue;
|
|
382
|
+
await removeWorktreeDir(cwd, entry.worktree);
|
|
383
|
+
if (entry.branch) {
|
|
384
|
+
try { await execGit(['branch', '-D', entry.branch], { cwd, timeout: 10_000 }); } catch { /* already gone */ }
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
try { await execGit(['worktree', 'prune'], { cwd, timeout: 10_000 }); } catch { /* best effort */ }
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
// ──────────────────────────────────────────── kind-scoped convenience wrappers
|
|
392
|
+
|
|
393
|
+
async function createJobWorktree({ cwd, slug }) {
|
|
394
|
+
return createWorktree({ kind: 'job', cwd, key: slug });
|
|
395
|
+
}
|
|
396
|
+
async function integrateJobBranch({ cwd, branch, slug }) {
|
|
397
|
+
return integrateBranch({ kind: 'job', cwd, branch, key: slug });
|
|
398
|
+
}
|
|
399
|
+
async function cleanupJobWorktree({ cwd, dir, branch, keepBranch }) {
|
|
400
|
+
return cleanupWorktree({ kind: 'job', cwd, dir, branch, keepBranch });
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
async function createEpicWorktree({ cwd, epicId }) {
|
|
404
|
+
return createWorktree({ kind: 'epic', cwd, key: epicId });
|
|
405
|
+
}
|
|
406
|
+
async function integrateEpicBranch({ cwd, branch, epicId }) {
|
|
407
|
+
return integrateBranch({ kind: 'epic', cwd, branch, key: epicId });
|
|
408
|
+
}
|
|
409
|
+
async function cleanupEpicWorktree({ cwd, dir, branch, keepBranch }) {
|
|
410
|
+
return cleanupWorktree({ kind: 'epic', cwd, dir, branch, keepBranch });
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
module.exports = {
|
|
414
|
+
KIND_CONFIG,
|
|
415
|
+
worktreeRootFor,
|
|
416
|
+
isWorktreeDisabled,
|
|
417
|
+
getMaxConcurrentWorktrees,
|
|
418
|
+
isGitRepo,
|
|
419
|
+
isBaseTreeClean,
|
|
420
|
+
worktreeDirFor,
|
|
421
|
+
branchNameFor,
|
|
422
|
+
keyFromBranch,
|
|
423
|
+
createWorktree,
|
|
424
|
+
integrateBranch,
|
|
425
|
+
cleanupWorktree,
|
|
426
|
+
parseWorktreeListPorcelain,
|
|
427
|
+
reconcileWorktreesOnBoot,
|
|
428
|
+
// Job-kind convenience wrappers — same call shape jobWorktree.cjs has
|
|
429
|
+
// always exposed.
|
|
430
|
+
createJobWorktree,
|
|
431
|
+
integrateJobBranch,
|
|
432
|
+
cleanupJobWorktree,
|
|
433
|
+
// Epic-kind convenience wrappers.
|
|
434
|
+
createEpicWorktree,
|
|
435
|
+
integrateEpicBranch,
|
|
436
|
+
cleanupEpicWorktree,
|
|
437
|
+
// Test-only escape hatch for the in-memory per-kind concurrency counters.
|
|
438
|
+
_resetActiveWorktreeCountForTests(kind, n = 0) {
|
|
439
|
+
configFor(kind);
|
|
440
|
+
activeWorktreeCount[kind] = n;
|
|
441
|
+
},
|
|
442
|
+
_getActiveWorktreeCountForTests(kind) {
|
|
443
|
+
configFor(kind);
|
|
444
|
+
return activeWorktreeCount[kind];
|
|
445
|
+
},
|
|
446
|
+
};
|