@ulysses-ai/create-workspace 0.17.0-beta.0 → 0.18.0-beta.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/README.md +3 -3
- package/package.json +1 -1
- package/template/.claude/hooks/_utils.mjs +1 -1
- package/template/.claude/hooks/repo-write-detection.mjs +161 -64
- package/template/.claude/hooks/session-start.mjs +35 -1
- package/template/.claude/hooks/subagent-start.mjs +89 -22
- package/template/.claude/lib/session-frontmatter.mjs +28 -0
- package/template/.claude/rules/coherent-revisions.md +1 -1
- package/template/.claude/rules/forge-operations.md +37 -93
- package/template/.claude/rules/git-conventions.md +16 -11
- package/template/.claude/rules/goal-driven-work.md +8 -416
- package/template/.claude/rules/honest-pushback.md +37 -37
- package/template/.claude/rules/memory-guidance.md +43 -90
- package/template/.claude/rules/superpowers-workflow.md.skip +1 -1
- package/template/.claude/rules/work-item-tracking.md +30 -72
- package/template/.claude/rules/workspace-structure.md +36 -94
- package/template/.claude/scripts/build-workspace-context.mjs +61 -16
- package/template/.claude/scripts/chat-record.mjs +282 -0
- package/template/.claude/scripts/cleanup-work-session.mjs +257 -68
- package/template/.claude/scripts/context-footprint.mjs +282 -0
- package/template/.claude/scripts/forges/github.mjs +45 -0
- package/template/.claude/scripts/forges/gitlab.mjs +3 -2
- package/template/.claude/scripts/forges/interface.mjs +12 -0
- package/template/.claude/scripts/generate-claude-local.mjs +21 -2
- package/template/.claude/scripts/migrate-sessions.mjs +1571 -0
- package/template/.claude/scripts/migrate-to-workspace-context.mjs +7 -2
- package/template/.claude/scripts/task-worktree.mjs +525 -0
- package/template/.claude/scripts/workspace-diagnostics.mjs +654 -0
- package/template/.claude/skills/braindump/SKILL.md +11 -4
- package/template/.claude/skills/build-docs-site/SKILL.md +5 -5
- package/template/.claude/skills/build-docs-site/templates/spec.md.tmpl +1 -1
- package/template/.claude/skills/complete-work/SKILL.md +229 -219
- package/template/.claude/skills/context-placement/SKILL.md +199 -0
- package/template/.claude/skills/goal-driven-work/SKILL.md +459 -0
- package/template/.claude/skills/handoff/SKILL.md +11 -4
- package/template/.claude/skills/maintenance/SKILL.md +7 -0
- package/template/.claude/skills/migrate-sessions/SKILL.md +70 -0
- package/template/.claude/skills/pause-work/SKILL.md +9 -1
- package/template/.claude/skills/release/SKILL.md +44 -108
- package/template/.claude/skills/start-work/SKILL.md +89 -7
- package/template/.claude/skills/workspace-init/SKILL.md +3 -1
- package/template/.claude/skills/workspace-update/SKILL.md +4 -0
- package/template/CLAUDE.md.tmpl +19 -2
- package/template/_gitignore +9 -0
- package/template/workspace.json.tmpl +3 -2
|
@@ -21,14 +21,28 @@
|
|
|
21
21
|
// no-op and the script reports success while leaving orphans behind
|
|
22
22
|
// (gh:119).
|
|
23
23
|
//
|
|
24
|
+
// SECURITY (gh:147/B5): the tracker is session-controlled input. Every
|
|
25
|
+
// git call is an argv array through execFileSync — never a shell string —
|
|
26
|
+
// because a crafted tracker (`branch: 'x"; touch pwn; echo "'`) used to
|
|
27
|
+
// reach execSync as shell text and execute. Beyond injection, the
|
|
28
|
+
// validated values must stay INSIDE this workspace: repos entries must be
|
|
29
|
+
// single path segments resolving under root/repos/ (a tracker claiming
|
|
30
|
+
// `repos: ['../../outside']` used to delete branches in a repo outside
|
|
31
|
+
// the workspace), branch names must pass `git check-ref-format`, and the
|
|
32
|
+
// session name must be a single path segment. All validation runs before
|
|
33
|
+
// anything is touched.
|
|
34
|
+
//
|
|
24
35
|
// `success: true` means VERIFIED: every project worktree record is gone
|
|
25
36
|
// (no prunable entries left over), every local branch is deleted, and the
|
|
26
37
|
// session folder is removed. The script post-verifies all of these and
|
|
27
|
-
// surfaces any leftover state as an error rather than swallowing it.
|
|
38
|
+
// surfaces any leftover state as an error rather than swallowing it. If
|
|
39
|
+
// any step records an error, the session folder is KEPT (N6) — removing
|
|
40
|
+
// it would destroy the tracker and worktrees the next run needs to
|
|
41
|
+
// recover with.
|
|
28
42
|
import '../lib/require-node.mjs';
|
|
29
|
-
import {
|
|
30
|
-
import { existsSync, readdirSync, statSync } from 'fs';
|
|
31
|
-
import { join } from 'path';
|
|
43
|
+
import { execFileSync } from 'child_process';
|
|
44
|
+
import { existsSync, readdirSync, statSync, lstatSync, realpathSync } from 'fs';
|
|
45
|
+
import { join, resolve, isAbsolute, sep } from 'path';
|
|
32
46
|
import {
|
|
33
47
|
getWorkspaceRoot,
|
|
34
48
|
readSessionTracker,
|
|
@@ -37,6 +51,38 @@ import {
|
|
|
37
51
|
normalizeRepos,
|
|
38
52
|
} from '../hooks/_utils.mjs';
|
|
39
53
|
|
|
54
|
+
// No shell anywhere: args go to execFileSync as an array, so metachar-
|
|
55
|
+
// acters in tracker-controlled values are data, never syntax. A failed
|
|
56
|
+
// call returns {ok:false} with git's stderr — the caller decides whether
|
|
57
|
+
// that is a skip or an error.
|
|
58
|
+
function git(cwd, args) {
|
|
59
|
+
try {
|
|
60
|
+
const out = execFileSync('git', ['-C', cwd, ...args], {
|
|
61
|
+
encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe'],
|
|
62
|
+
});
|
|
63
|
+
return { ok: true, out };
|
|
64
|
+
} catch (err) {
|
|
65
|
+
return { ok: false, out: String(err.stdout ?? ''), err: String(err.stderr ?? err.message).trim() };
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// .native resolves Windows 8.3 short names; the plain fallback covers
|
|
70
|
+
// filesystems where the native binding is unavailable.
|
|
71
|
+
function realOf(p) {
|
|
72
|
+
try { return realpathSync.native(p); } catch { /* fall through */ }
|
|
73
|
+
try { return realpathSync(p); } catch { /* fall through */ }
|
|
74
|
+
return resolve(p);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// One path segment, not dots, not absolute — the shape of a repo name
|
|
78
|
+
// under root/repos/. "./x", "../y", "a/b", and absolute paths all fail.
|
|
79
|
+
function isRepoSegment(name) {
|
|
80
|
+
if (typeof name !== 'string' || name === '' || isAbsolute(name)) return false;
|
|
81
|
+
const segs = name.split(/[\\/]/);
|
|
82
|
+
if (segs.length !== 1) return false;
|
|
83
|
+
return !/^\.+$/.test(segs[0]);
|
|
84
|
+
}
|
|
85
|
+
|
|
40
86
|
const args = process.argv.slice(2);
|
|
41
87
|
const getArg = (name) => {
|
|
42
88
|
const idx = args.indexOf(`--${name}`);
|
|
@@ -49,6 +95,17 @@ if (!sessionName) {
|
|
|
49
95
|
process.exit(1);
|
|
50
96
|
}
|
|
51
97
|
|
|
98
|
+
// Same segment rule as the repo names: the session name becomes a path
|
|
99
|
+
// under the sessions directory, and traversal out of it is not cleanup.
|
|
100
|
+
const isSessionSegment = (name) => isRepoSegment(name);
|
|
101
|
+
if (!isSessionSegment(sessionName)) {
|
|
102
|
+
console.log(JSON.stringify({
|
|
103
|
+
success: false,
|
|
104
|
+
errors: [`invalid session name "${sessionName}" — must be a single path segment`],
|
|
105
|
+
}));
|
|
106
|
+
process.exit(1);
|
|
107
|
+
}
|
|
108
|
+
|
|
52
109
|
const root = getWorkspaceRoot(import.meta.url);
|
|
53
110
|
const reposDir = join(root, 'repos');
|
|
54
111
|
const sessionFolder = sessionFolderPath(root, sessionName);
|
|
@@ -58,6 +115,10 @@ const removed = [];
|
|
|
58
115
|
const skipped = [];
|
|
59
116
|
const errors = [];
|
|
60
117
|
|
|
118
|
+
// The real path of root/repos/ — the hard boundary every repos entry
|
|
119
|
+
// must resolve inside (computed once; root itself is trusted).
|
|
120
|
+
const reposRootReal = realOf(reposDir);
|
|
121
|
+
|
|
61
122
|
// === Discovery: where session.md is silent or missing, fall back to disk ===
|
|
62
123
|
//
|
|
63
124
|
// The tracker may have been stripped before this script runs. Read whatever
|
|
@@ -70,22 +131,24 @@ let branch = tracker?.branch || null;
|
|
|
70
131
|
// If repos is empty, discover from work-sessions/{name}/workspace/repos/.
|
|
71
132
|
// That directory is the workspace worktree's nested-project-worktrees dir;
|
|
72
133
|
// each entry is one project repo this session checked out.
|
|
134
|
+
let discovered = false;
|
|
73
135
|
if (repos.length === 0) {
|
|
74
136
|
const nestedReposDir = join(wsWorktree, 'repos');
|
|
75
137
|
if (existsSync(nestedReposDir)) {
|
|
76
138
|
try {
|
|
77
|
-
const
|
|
139
|
+
const found = readdirSync(nestedReposDir).filter((entry) => {
|
|
78
140
|
try {
|
|
79
141
|
return statSync(join(nestedReposDir, entry)).isDirectory();
|
|
80
142
|
} catch {
|
|
81
143
|
return false;
|
|
82
144
|
}
|
|
83
145
|
});
|
|
84
|
-
if (
|
|
85
|
-
repos =
|
|
146
|
+
if (found.length > 0) {
|
|
147
|
+
repos = found;
|
|
148
|
+
discovered = true;
|
|
86
149
|
skipped.push({
|
|
87
150
|
step: 'discovery',
|
|
88
|
-
reason: `Tracker missing repos; discovered ${
|
|
151
|
+
reason: `Tracker missing repos; discovered ${found.length} from ${nestedReposDir}: ${found.join(', ')}`,
|
|
89
152
|
});
|
|
90
153
|
}
|
|
91
154
|
} catch (err) {
|
|
@@ -94,18 +157,130 @@ if (repos.length === 0) {
|
|
|
94
157
|
}
|
|
95
158
|
}
|
|
96
159
|
|
|
160
|
+
// Validate every repo entry BEFORE any git runs against it (B5): each
|
|
161
|
+
// must be a single path segment whose real path stays inside root/repos/
|
|
162
|
+
// — a crafted tracker (`repos: ['../../outside']`) must never point this
|
|
163
|
+
// script at a repository outside the workspace. Entries discovered from
|
|
164
|
+
// disk are validated too: they become paths just the same.
|
|
165
|
+
//
|
|
166
|
+
// One supported exception: repos/{name} may be a SYMLINK to the source
|
|
167
|
+
// clone that owns the nested worktree (a layout some machines use to
|
|
168
|
+
// share clones). That is allowed only when the nested worktree's git
|
|
169
|
+
// common dir resolves into the very repository the symlink points at —
|
|
170
|
+
// anything else is an unverified entry and refused.
|
|
171
|
+
const nestedCommonDir = (repo) => {
|
|
172
|
+
const wt = join(wsWorktree, 'repos', repo);
|
|
173
|
+
if (!existsSync(wt)) return null;
|
|
174
|
+
const res = git(wt, ['rev-parse', '--git-common-dir']);
|
|
175
|
+
if (!res.ok) return null;
|
|
176
|
+
return realOf(resolve(wt, res.out.trim()));
|
|
177
|
+
};
|
|
178
|
+
const repoCommonDir = (repoDir) => {
|
|
179
|
+
const res = git(repoDir, ['rev-parse', '--git-common-dir']);
|
|
180
|
+
if (!res.ok) return null;
|
|
181
|
+
return realOf(resolve(repoDir, res.out.trim()));
|
|
182
|
+
};
|
|
183
|
+
for (const repo of repos) {
|
|
184
|
+
const source = discovered ? 'discovered from disk' : 'in the session tracker';
|
|
185
|
+
if (!isRepoSegment(repo)) {
|
|
186
|
+
errors.push(`Invalid repos entry "${repo}" ${source}: must be a single path segment`);
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
const dir = join(reposDir, repo);
|
|
190
|
+
let st = null;
|
|
191
|
+
try { st = lstatSync(dir); } catch { /* handled below */ }
|
|
192
|
+
if (st && st.isSymbolicLink()) {
|
|
193
|
+
const target = realOf(dir);
|
|
194
|
+
const targetCommon = existsSync(target) ? repoCommonDir(target) : null;
|
|
195
|
+
const nestedCommon = nestedCommonDir(repo);
|
|
196
|
+
if (!targetCommon || !nestedCommon || targetCommon !== nestedCommon) {
|
|
197
|
+
errors.push(`Invalid repos entry "${repo}" ${source}: repos/${repo} is a symlink whose target is not the repository the nested worktree belongs to`);
|
|
198
|
+
}
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
201
|
+
const dirReal = realOf(dir);
|
|
202
|
+
if (dirReal !== reposRootReal && !dirReal.startsWith(reposRootReal + sep)) {
|
|
203
|
+
errors.push(`Invalid repos entry "${repo}" ${source}: resolves outside ${reposDir}`);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
97
207
|
// If branch is missing, ask the workspace worktree itself.
|
|
98
208
|
if (!branch && existsSync(wsWorktree)) {
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
209
|
+
const res = git(wsWorktree, ['rev-parse', '--abbrev-ref', 'HEAD']);
|
|
210
|
+
if (res.ok && res.out.trim() !== '' && res.out.trim() !== 'HEAD') {
|
|
211
|
+
branch = res.out.trim();
|
|
212
|
+
skipped.push({ step: 'discovery', reason: `Tracker missing branch; discovered from worktree: ${branch}` });
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// Removing the workspace worktree (step 2, --force) deletes everything
|
|
217
|
+
// still inside it. So before anything runs, every OTHER thing inside it
|
|
218
|
+
// must be accounted for:
|
|
219
|
+
// - each directory under workspace/repos/ must be a repo this teardown
|
|
220
|
+
// handles (a tracker listing fewer repos than the session holds must
|
|
221
|
+
// not decide it); stray files there are named, with Finder/Explorer
|
|
222
|
+
// junk exempt;
|
|
223
|
+
// - no repository this workspace owns may have a worktree registered
|
|
224
|
+
// anywhere under the workspace worktree except the ones step 1 removes
|
|
225
|
+
// — a worktree nested elsewhere would be deleted with its contents.
|
|
226
|
+
{
|
|
227
|
+
const NOISE = new Set(['.DS_Store', 'Thumbs.db', 'desktop.ini']);
|
|
228
|
+
const nestedReposDir = join(wsWorktree, 'repos');
|
|
229
|
+
if (existsSync(nestedReposDir)) {
|
|
230
|
+
let entries = [];
|
|
231
|
+
try { entries = readdirSync(nestedReposDir, { withFileTypes: true }); } catch { /* unreadable: the worktree scan below still runs */ }
|
|
232
|
+
const unlistedDirs = entries.filter((e) => e.isDirectory() && !repos.includes(e.name)).map((e) => e.name);
|
|
233
|
+
const strayFiles = entries.filter((e) => !e.isDirectory() && !NOISE.has(e.name)).map((e) => e.name);
|
|
234
|
+
if (unlistedDirs.length > 0) {
|
|
235
|
+
errors.push(`${nestedReposDir} holds [${unlistedDirs.join(', ')}] that this teardown does not handle — removing the workspace worktree would delete them; add them to the session tracker or move them out first`);
|
|
236
|
+
}
|
|
237
|
+
if (strayFiles.length > 0) {
|
|
238
|
+
errors.push(`${nestedReposDir} holds file(s) [${strayFiles.join(', ')}] that removing the workspace worktree would delete — move or delete them first`);
|
|
103
239
|
}
|
|
104
|
-
}
|
|
105
|
-
|
|
240
|
+
}
|
|
241
|
+
const handled = new Set([realOf(wsWorktree), ...repos.map((r) => realOf(join(wsWorktree, 'repos', r)))]);
|
|
242
|
+
const wsReal = realOf(wsWorktree);
|
|
243
|
+
const ownedRepoDirs = [root];
|
|
244
|
+
if (existsSync(reposDir)) {
|
|
245
|
+
try {
|
|
246
|
+
for (const e of readdirSync(reposDir, { withFileTypes: true })) {
|
|
247
|
+
if (e.isDirectory() && isRepoSegment(e.name)) ownedRepoDirs.push(join(reposDir, e.name));
|
|
248
|
+
}
|
|
249
|
+
} catch { /* no repos dir to scan */ }
|
|
250
|
+
}
|
|
251
|
+
for (const repoDir of ownedRepoDirs) {
|
|
252
|
+
const res = git(repoDir, ['worktree', 'list', '--porcelain']);
|
|
253
|
+
if (!res.ok) continue;
|
|
254
|
+
for (const line of res.out.split(/\r?\n/)) {
|
|
255
|
+
if (!line.startsWith('worktree ')) continue;
|
|
256
|
+
const p = realOf(line.slice(9));
|
|
257
|
+
if ((p === wsReal || p.startsWith(wsReal + sep)) && !handled.has(p)) {
|
|
258
|
+
errors.push(`${repoDir} has a worktree at ${p}, inside the workspace worktree but not one this teardown removes — removing the workspace worktree would delete it; move or remove it first`);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// Branch names become refs and git command arguments; git's own format
|
|
265
|
+
// check is the authority and also rejects shell-metacharacter payloads
|
|
266
|
+
// long before any later use.
|
|
267
|
+
if (branch) {
|
|
268
|
+
const res = git(root, ['check-ref-format', '--branch', branch]);
|
|
269
|
+
if (!res.ok) {
|
|
270
|
+
errors.push(`Invalid branch "${branch}" in the session tracker: git check-ref-format rejected it`);
|
|
106
271
|
}
|
|
107
272
|
}
|
|
108
273
|
|
|
274
|
+
if (errors.length > 0) {
|
|
275
|
+
console.log(JSON.stringify({
|
|
276
|
+
success: false,
|
|
277
|
+
removed,
|
|
278
|
+
skipped: skipped.length > 0 ? skipped : undefined,
|
|
279
|
+
errors,
|
|
280
|
+
}));
|
|
281
|
+
process.exit(1);
|
|
282
|
+
}
|
|
283
|
+
|
|
109
284
|
// === Step 1: Remove each project worktree FIRST, from its project repo ===
|
|
110
285
|
for (const repo of repos) {
|
|
111
286
|
const projWorktree = join(wsWorktree, 'repos', repo);
|
|
@@ -118,21 +293,40 @@ for (const repo of repos) {
|
|
|
118
293
|
errors.push(`Cannot remove ${repo} worktree: source clone missing at ${repoDir}`);
|
|
119
294
|
continue;
|
|
120
295
|
}
|
|
121
|
-
|
|
122
|
-
|
|
296
|
+
const res = git(repoDir, ['worktree', 'remove', projWorktree, '--force']);
|
|
297
|
+
if (res.ok) {
|
|
123
298
|
removed.push(`project worktree ${repo}`);
|
|
124
|
-
}
|
|
125
|
-
errors.push(`Failed to remove ${repo} worktree: ${err.
|
|
299
|
+
} else {
|
|
300
|
+
errors.push(`Failed to remove ${repo} worktree: ${res.err || res.out}`);
|
|
126
301
|
}
|
|
127
302
|
}
|
|
128
303
|
|
|
304
|
+
// === HARD STOP if step 1 recorded any error ===
|
|
305
|
+
//
|
|
306
|
+
// A nested entry that could not be removed is an entry whose nature was
|
|
307
|
+
// never verified (a plain directory, a foreign clone, a live worktree in
|
|
308
|
+
// an unexpected state). Continuing would run `branch -D` in repos whose
|
|
309
|
+
// nested entry was not verified as its worktree, and would delete the
|
|
310
|
+
// session folder around whatever survived — the exact data-loss shape
|
|
311
|
+
// the review probes demonstrated. Stop, report, keep everything.
|
|
312
|
+
if (errors.length > 0) {
|
|
313
|
+
errors.push('Stopped after step 1 (nested worktree removal) — steps 2-5 skipped; nothing else was touched');
|
|
314
|
+
console.log(JSON.stringify({
|
|
315
|
+
success: false,
|
|
316
|
+
removed,
|
|
317
|
+
skipped: skipped.length > 0 ? skipped : undefined,
|
|
318
|
+
errors,
|
|
319
|
+
}));
|
|
320
|
+
process.exit(1);
|
|
321
|
+
}
|
|
322
|
+
|
|
129
323
|
// === Step 2: Remove the workspace worktree AFTER project worktrees are gone ===
|
|
130
324
|
if (existsSync(wsWorktree)) {
|
|
131
|
-
|
|
132
|
-
|
|
325
|
+
const res = git(root, ['worktree', 'remove', wsWorktree, '--force']);
|
|
326
|
+
if (res.ok) {
|
|
133
327
|
removed.push('workspace worktree');
|
|
134
|
-
}
|
|
135
|
-
errors.push(`Failed to remove workspace worktree: ${err.
|
|
328
|
+
} else {
|
|
329
|
+
errors.push(`Failed to remove workspace worktree: ${res.err || res.out}`);
|
|
136
330
|
}
|
|
137
331
|
} else {
|
|
138
332
|
skipped.push({ step: 'remove-workspace-worktree', reason: `${wsWorktree} does not exist` });
|
|
@@ -142,12 +336,11 @@ if (existsSync(wsWorktree)) {
|
|
|
142
336
|
for (const repo of repos) {
|
|
143
337
|
const repoDir = join(reposDir, repo);
|
|
144
338
|
if (!existsSync(repoDir)) continue;
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
} catch (err) {
|
|
339
|
+
const res = git(repoDir, ['worktree', 'prune']);
|
|
340
|
+
if (!res.ok) {
|
|
148
341
|
// Prune is a safety net, but if it fails on a repo we touched, surface
|
|
149
342
|
// it — verification below will catch leftover orphans either way.
|
|
150
|
-
errors.push(`Prune failed in ${repo}: ${err.
|
|
343
|
+
errors.push(`Prune failed in ${repo}: ${res.err || res.out}`);
|
|
151
344
|
}
|
|
152
345
|
}
|
|
153
346
|
|
|
@@ -156,47 +349,48 @@ if (branch) {
|
|
|
156
349
|
for (const repo of repos) {
|
|
157
350
|
const repoDir = join(reposDir, repo);
|
|
158
351
|
if (!existsSync(repoDir)) continue;
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
if (!exists) continue; // Already gone (e.g., gh pr merge --delete-branch did it).
|
|
166
|
-
try {
|
|
167
|
-
execSync(`git branch -D "${branch}"`, { cwd: repoDir, stdio: 'pipe' });
|
|
168
|
-
} catch (err) {
|
|
169
|
-
errors.push(`Failed to delete branch ${branch} in ${repo}: ${err.message.trim()}`);
|
|
352
|
+
const listed = git(repoDir, ['branch', '--list', branch]);
|
|
353
|
+
if (!listed.ok) continue; // Repo broken; verification will catch downstream impact.
|
|
354
|
+
if (listed.out.trim() === '') continue; // Already gone (e.g., gh pr merge --delete-branch did it).
|
|
355
|
+
const del = git(repoDir, ['branch', '-D', branch]);
|
|
356
|
+
if (!del.ok) {
|
|
357
|
+
errors.push(`Failed to delete branch ${branch} in ${repo}: ${del.err || del.out}`);
|
|
170
358
|
}
|
|
171
359
|
}
|
|
172
360
|
// Same for the workspace repo (root).
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
} catch (err) {
|
|
179
|
-
errors.push(`Failed to delete branch ${branch} in workspace repo: ${err.message.trim()}`);
|
|
180
|
-
}
|
|
361
|
+
const listed = git(root, ['branch', '--list', branch]);
|
|
362
|
+
if (listed.ok && listed.out.trim() !== '') {
|
|
363
|
+
const del = git(root, ['branch', '-D', branch]);
|
|
364
|
+
if (!del.ok) {
|
|
365
|
+
errors.push(`Failed to delete branch ${branch} in workspace repo: ${del.err || del.out}`);
|
|
181
366
|
}
|
|
182
|
-
} catch {
|
|
183
|
-
// workspace root not a git repo — unusual, skip.
|
|
184
367
|
}
|
|
185
368
|
}
|
|
186
369
|
|
|
187
|
-
// === Step 5: Delete the whole work-sessions/{name}/ folder ===
|
|
188
|
-
try {
|
|
189
|
-
deleteSessionFolder(root, sessionName);
|
|
190
|
-
} catch (err) {
|
|
191
|
-
errors.push(`Failed to delete session folder ${sessionFolder}: ${err.message.trim()}`);
|
|
192
|
-
}
|
|
193
|
-
|
|
194
370
|
// === Post-verification: success means VERIFIED, not "no try/catch threw" ===
|
|
195
371
|
//
|
|
196
372
|
// Without these checks, an empty repos list (the gh:119 root cause) lets
|
|
197
373
|
// every silent skip add up to a "success" output while leaving orphans
|
|
198
374
|
// behind. The verification turns silent skips into honest errors.
|
|
199
375
|
|
|
376
|
+
if (existsSync(sessionFolder)) {
|
|
377
|
+
// The folder is still there — either Step 5 has not run (errors above)
|
|
378
|
+
// or it failed. Only delete it when everything before it succeeded
|
|
379
|
+
// (N6): the folder holds the tracker and any remaining worktrees the
|
|
380
|
+
// next recovery run needs.
|
|
381
|
+
if (errors.length === 0) {
|
|
382
|
+
try {
|
|
383
|
+
deleteSessionFolder(root, sessionName);
|
|
384
|
+
} catch (err) {
|
|
385
|
+
errors.push(`Failed to delete session folder ${sessionFolder}: ${err.message.trim()}`);
|
|
386
|
+
}
|
|
387
|
+
} else {
|
|
388
|
+
errors.push('Session folder kept — earlier steps failed; reconcile and re-run');
|
|
389
|
+
}
|
|
390
|
+
} else {
|
|
391
|
+
skipped.push({ step: 'delete-session-folder', reason: `${sessionFolder} does not exist` });
|
|
392
|
+
}
|
|
393
|
+
|
|
200
394
|
if (existsSync(sessionFolder)) {
|
|
201
395
|
errors.push(`Session folder still present after cleanup: ${sessionFolder}`);
|
|
202
396
|
}
|
|
@@ -205,13 +399,12 @@ const wsPath = wsWorktree; // canonical path the worktree had
|
|
|
205
399
|
for (const repo of repos) {
|
|
206
400
|
const repoDir = join(reposDir, repo);
|
|
207
401
|
if (!existsSync(repoDir)) continue;
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
} catch (err) {
|
|
212
|
-
errors.push(`Could not list worktrees in ${repo}: ${err.message.trim()}`);
|
|
402
|
+
const listRes = git(repoDir, ['worktree', 'list', '--porcelain']);
|
|
403
|
+
if (!listRes.ok) {
|
|
404
|
+
errors.push(`Could not list worktrees in ${repo}: ${listRes.err || listRes.out}`);
|
|
213
405
|
continue;
|
|
214
406
|
}
|
|
407
|
+
const wtList = listRes.out;
|
|
215
408
|
if (wtList.includes('prunable')) {
|
|
216
409
|
errors.push(`Prunable worktree record remains in ${repo} after cleanup (gh:119 symptom)`);
|
|
217
410
|
}
|
|
@@ -219,21 +412,17 @@ for (const repo of repos) {
|
|
|
219
412
|
errors.push(`${repo} still has a worktree record referencing the session path`);
|
|
220
413
|
}
|
|
221
414
|
if (branch) {
|
|
222
|
-
const
|
|
223
|
-
if (
|
|
415
|
+
const stillRes = git(repoDir, ['branch', '--list', branch]);
|
|
416
|
+
if (stillRes.ok && stillRes.out.trim() !== '') {
|
|
224
417
|
errors.push(`Branch ${branch} still present in ${repo} after cleanup`);
|
|
225
418
|
}
|
|
226
419
|
}
|
|
227
420
|
}
|
|
228
421
|
|
|
229
422
|
if (branch) {
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
errors.push(`Branch ${branch} still present in workspace repo after cleanup`);
|
|
234
|
-
}
|
|
235
|
-
} catch {
|
|
236
|
-
// not a git repo — already noted
|
|
423
|
+
const stillRes = git(root, ['branch', '--list', branch]);
|
|
424
|
+
if (stillRes.ok && stillRes.out.trim() !== '') {
|
|
425
|
+
errors.push(`Branch ${branch} still present in workspace repo after cleanup`);
|
|
237
426
|
}
|
|
238
427
|
}
|
|
239
428
|
|