@ulysses-ai/create-workspace 0.17.0-beta.0 → 0.19.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/lib/init.mjs +4 -1
- package/lib/payload.mjs +18 -1
- package/lib/payload.test.mjs +55 -0
- package/lib/scaffold.mjs +23 -6
- package/lib/scaffold.test.mjs +59 -0
- package/package.json +1 -1
- package/template/CLAUDE.md.tmpl +19 -2
- package/template/{.claude → _claude}/hooks/_utils.mjs +1 -1
- package/template/_claude/hooks/repo-write-detection.mjs +204 -0
- package/template/{.claude → _claude}/hooks/session-start.mjs +35 -1
- package/template/_claude/hooks/subagent-start.mjs +111 -0
- package/template/{.claude → _claude}/lib/session-frontmatter.mjs +28 -0
- package/template/{.claude → _claude}/rules/coherent-revisions.md +1 -1
- package/template/_claude/rules/forge-operations.md +57 -0
- package/template/_claude/rules/git-conventions.md +39 -0
- package/template/_claude/rules/goal-driven-work.md +24 -0
- package/template/_claude/rules/honest-pushback.md +56 -0
- package/template/_claude/rules/memory-guidance.md +66 -0
- package/template/{.claude → _claude}/rules/superpowers-workflow.md.skip +1 -1
- package/template/{.claude → _claude}/rules/task-list-mirroring.md +6 -0
- package/template/_claude/rules/work-item-tracking.md +48 -0
- package/template/_claude/rules/workspace-structure.md +79 -0
- package/template/{.claude → _claude}/scripts/build-workspace-context.mjs +86 -30
- package/template/_claude/scripts/chat-record.mjs +315 -0
- package/template/_claude/scripts/cleanup-work-session.mjs +436 -0
- package/template/_claude/scripts/context-footprint.mjs +391 -0
- package/template/{.claude → _claude}/scripts/forges/github.mjs +46 -0
- package/template/{.claude → _claude}/scripts/forges/gitlab.mjs +3 -2
- package/template/{.claude → _claude}/scripts/forges/interface.mjs +13 -0
- package/template/{.claude → _claude}/scripts/generate-claude-local.mjs +21 -2
- package/template/_claude/scripts/migrate-sessions.mjs +1571 -0
- package/template/{.claude → _claude}/scripts/migrate-to-workspace-context.mjs +7 -2
- package/template/_claude/scripts/task-pr.mjs +447 -0
- package/template/_claude/scripts/task-worktree.mjs +525 -0
- package/template/{.claude → _claude}/scripts/trackers/github-issues.mjs +11 -0
- package/template/{.claude → _claude}/scripts/trackers/interface.mjs +8 -0
- package/template/_claude/scripts/workspace-diagnostics.mjs +654 -0
- package/template/{.claude → _claude}/skills/braindump/SKILL.md +12 -4
- package/template/{.claude → _claude}/skills/build-docs-site/SKILL.md +5 -5
- package/template/{.claude → _claude}/skills/build-docs-site/templates/spec.md.tmpl +1 -1
- package/template/_claude/skills/complete-work/SKILL.md +452 -0
- package/template/_claude/skills/context-placement/SKILL.md +202 -0
- package/template/{.claude/rules/goal-driven-work.md → _claude/skills/goal-driven-work/SKILL.md} +46 -19
- package/template/{.claude → _claude}/skills/handoff/SKILL.md +12 -4
- package/template/{.claude → _claude}/skills/maintenance/SKILL.md +56 -17
- package/template/_claude/skills/migrate-sessions/SKILL.md +70 -0
- package/template/{.claude → _claude}/skills/pause-work/SKILL.md +9 -1
- package/template/_claude/skills/release/SKILL.md +91 -0
- package/template/{.claude → _claude}/skills/start-work/SKILL.md +89 -7
- package/template/{.claude → _claude}/skills/workspace-init/SKILL.md +3 -1
- package/template/{.claude → _claude}/skills/workspace-update/SKILL.md +4 -0
- package/template/_gitignore +9 -0
- package/template/workspace.json.tmpl +4 -3
- package/template/.claude/hooks/repo-write-detection.mjs +0 -107
- package/template/.claude/hooks/subagent-start.mjs +0 -44
- package/template/.claude/rules/forge-operations.md +0 -107
- package/template/.claude/rules/git-conventions.md +0 -34
- package/template/.claude/rules/honest-pushback.md +0 -56
- package/template/.claude/rules/memory-guidance.md +0 -109
- package/template/.claude/rules/work-item-tracking.md +0 -90
- package/template/.claude/rules/workspace-structure.md +0 -137
- package/template/.claude/scripts/cleanup-work-session.mjs +0 -247
- package/template/.claude/skills/complete-work/SKILL.md +0 -498
- package/template/.claude/skills/release/SKILL.md +0 -151
- /package/template/{.claude → _claude}/agents/aside-researcher.md +0 -0
- /package/template/{.claude → _claude}/agents/implementer.md +0 -0
- /package/template/{.claude → _claude}/agents/researcher.md +0 -0
- /package/template/{.claude → _claude}/agents/reviewer.md +0 -0
- /package/template/{.claude → _claude}/hooks/bash-output-advisory.mjs +0 -0
- /package/template/{.claude → _claude}/hooks/post-compact.mjs +0 -0
- /package/template/{.claude → _claude}/hooks/pre-compact.mjs +0 -0
- /package/template/{.claude → _claude}/hooks/session-end.mjs +0 -0
- /package/template/{.claude → _claude}/hooks/version-freshness-check.mjs +0 -0
- /package/template/{.claude → _claude}/hooks/workspace-update-check.mjs +0 -0
- /package/template/{.claude → _claude}/lib/freshness.mjs +0 -0
- /package/template/{.claude → _claude}/lib/registry-check.mjs +0 -0
- /package/template/{.claude → _claude}/lib/require-node.mjs +0 -0
- /package/template/{.claude → _claude}/recipes/migrate-from-notion.md +0 -0
- /package/template/{.claude → _claude}/rules/agent-rules.md.skip +0 -0
- /package/template/{.claude → _claude}/rules/cloud-infrastructure.md.skip +0 -0
- /package/template/{.claude → _claude}/rules/config-review.md.skip +0 -0
- /package/template/{.claude → _claude}/rules/documentation.md.skip +0 -0
- /package/template/{.claude → _claude}/rules/local-dev-environment.md.skip +0 -0
- /package/template/{.claude → _claude}/rules/product-integrity.md.skip +0 -0
- /package/template/{.claude → _claude}/rules/scope-guard.md.skip +0 -0
- /package/template/{.claude → _claude}/rules/token-economics.md.skip +0 -0
- /package/template/{.claude → _claude}/scripts/add-repo-to-session.mjs +0 -0
- /package/template/{.claude → _claude}/scripts/capture-context.mjs +0 -0
- /package/template/{.claude → _claude}/scripts/create-work-session.mjs +0 -0
- /package/template/{.claude → _claude}/scripts/migrate-canonical-priority.mjs +0 -0
- /package/template/{.claude → _claude}/scripts/migrate-claude-md-freshness-include.mjs +0 -0
- /package/template/{.claude → _claude}/scripts/migrate-open-work.mjs +0 -0
- /package/template/{.claude → _claude}/scripts/migrate-session-layout.mjs +0 -0
- /package/template/{.claude → _claude}/scripts/sweep-references.mjs +0 -0
- /package/template/{.claude → _claude}/scripts/sync-tasks.mjs +0 -0
- /package/template/{.claude → _claude}/settings.json +0 -0
- /package/template/{.claude → _claude}/skills/aside/SKILL.md +0 -0
- /package/template/{.claude → _claude}/skills/build-docs-site/checklists/framing.md +0 -0
- /package/template/{.claude → _claude}/skills/build-docs-site/checklists/pitfalls.md +0 -0
- /package/template/{.claude → _claude}/skills/build-docs-site/checklists/review.md +0 -0
- /package/template/{.claude → _claude}/skills/build-docs-site/scripts/bulk-fill-migration.py +0 -0
- /package/template/{.claude → _claude}/skills/build-docs-site/scripts/forbidden-word-grep.mjs +0 -0
- /package/template/{.claude → _claude}/skills/build-docs-site/scripts/leak-grep.mjs +0 -0
- /package/template/{.claude → _claude}/skills/build-docs-site/templates/custom.css.tmpl +0 -0
- /package/template/{.claude → _claude}/skills/build-docs-site/templates/docusaurus.config.ts.tmpl +0 -0
- /package/template/{.claude → _claude}/skills/build-docs-site/templates/primitives/Arrow.tsx +0 -0
- /package/template/{.claude → _claude}/skills/build-docs-site/templates/primitives/Box.tsx +0 -0
- /package/template/{.claude → _claude}/skills/build-docs-site/templates/primitives/DiagramContainer.tsx +0 -0
- /package/template/{.claude → _claude}/skills/build-docs-site/templates/primitives/Region.tsx +0 -0
- /package/template/{.claude → _claude}/skills/build-docs-site/templates/primitives/SectionTitle.tsx +0 -0
- /package/template/{.claude → _claude}/skills/build-docs-site/templates/primitives/tokens.ts +0 -0
- /package/template/{.claude → _claude}/skills/build-docs-site/templates/sidebars.ts.tmpl +0 -0
- /package/template/{.claude → _claude}/skills/promote/SKILL.md +0 -0
- /package/template/{.claude → _claude}/skills/setup-tracker/SKILL.md +0 -0
- /package/template/{.claude → _claude}/skills/sync-work/SKILL.md +0 -0
- /package/template/{.mcp.json → _mcp.json} +0 -0
|
@@ -0,0 +1,436 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Tear down a work session's worktrees, branches, and folder.
|
|
3
|
+
//
|
|
4
|
+
// Teardown order is MANDATORY:
|
|
5
|
+
// 1. Remove each project worktree from its project repo
|
|
6
|
+
// 2. Remove the workspace worktree from the workspace repo
|
|
7
|
+
// 3. Prune each project repo (belt-and-suspenders)
|
|
8
|
+
// 4. Delete all local branches
|
|
9
|
+
// 5. Remove the whole work-sessions/{name}/ folder
|
|
10
|
+
//
|
|
11
|
+
// Workspace-first removal silently deletes the nested project worktrees'
|
|
12
|
+
// .git files and leaves orphan worktree records in the project repos.
|
|
13
|
+
// The safe order keeps both sides of the relationship in sync.
|
|
14
|
+
//
|
|
15
|
+
// State discovery is defensive: the session tracker (session.md) may have
|
|
16
|
+
// been stripped by /complete-work Step 7 before this script runs, leaving
|
|
17
|
+
// no `repos:` or `branch:` to read. When that happens we discover repos
|
|
18
|
+
// from the work-sessions/{name}/workspace/repos/ directory listing and the
|
|
19
|
+
// branch from `git branch --show-current` on the workspace worktree —
|
|
20
|
+
// BEFORE removing anything. Without that, the per-repo loops silently
|
|
21
|
+
// no-op and the script reports success while leaving orphans behind
|
|
22
|
+
// (gh:119).
|
|
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
|
+
//
|
|
35
|
+
// `success: true` means VERIFIED: every project worktree record is gone
|
|
36
|
+
// (no prunable entries left over), every local branch is deleted, and the
|
|
37
|
+
// session folder is removed. The script post-verifies all of these and
|
|
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.
|
|
42
|
+
import '../lib/require-node.mjs';
|
|
43
|
+
import { execFileSync } from 'child_process';
|
|
44
|
+
import { existsSync, readdirSync, statSync, lstatSync, realpathSync } from 'fs';
|
|
45
|
+
import { join, resolve, isAbsolute, sep } from 'path';
|
|
46
|
+
import {
|
|
47
|
+
getWorkspaceRoot,
|
|
48
|
+
readSessionTracker,
|
|
49
|
+
deleteSessionFolder,
|
|
50
|
+
sessionFolderPath,
|
|
51
|
+
normalizeRepos,
|
|
52
|
+
} from '../hooks/_utils.mjs';
|
|
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
|
+
|
|
86
|
+
const args = process.argv.slice(2);
|
|
87
|
+
const getArg = (name) => {
|
|
88
|
+
const idx = args.indexOf(`--${name}`);
|
|
89
|
+
return idx >= 0 && args[idx + 1] ? args[idx + 1] : null;
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
const sessionName = getArg('session-name');
|
|
93
|
+
if (!sessionName) {
|
|
94
|
+
console.error('Usage: cleanup-work-session.mjs --session-name NAME');
|
|
95
|
+
process.exit(1);
|
|
96
|
+
}
|
|
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
|
+
|
|
109
|
+
const root = getWorkspaceRoot(import.meta.url);
|
|
110
|
+
const reposDir = join(root, 'repos');
|
|
111
|
+
const sessionFolder = sessionFolderPath(root, sessionName);
|
|
112
|
+
const wsWorktree = join(sessionFolder, 'workspace');
|
|
113
|
+
|
|
114
|
+
const removed = [];
|
|
115
|
+
const skipped = [];
|
|
116
|
+
const errors = [];
|
|
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
|
+
|
|
122
|
+
// === Discovery: where session.md is silent or missing, fall back to disk ===
|
|
123
|
+
//
|
|
124
|
+
// The tracker may have been stripped before this script runs. Read whatever
|
|
125
|
+
// is still there, then fill gaps from the live worktree state.
|
|
126
|
+
|
|
127
|
+
const tracker = readSessionTracker(root, sessionName);
|
|
128
|
+
let repos = normalizeRepos(tracker?.repos);
|
|
129
|
+
let branch = tracker?.branch || null;
|
|
130
|
+
|
|
131
|
+
// If repos is empty, discover from work-sessions/{name}/workspace/repos/.
|
|
132
|
+
// That directory is the workspace worktree's nested-project-worktrees dir;
|
|
133
|
+
// each entry is one project repo this session checked out.
|
|
134
|
+
let discovered = false;
|
|
135
|
+
if (repos.length === 0) {
|
|
136
|
+
const nestedReposDir = join(wsWorktree, 'repos');
|
|
137
|
+
if (existsSync(nestedReposDir)) {
|
|
138
|
+
try {
|
|
139
|
+
const found = readdirSync(nestedReposDir).filter((entry) => {
|
|
140
|
+
try {
|
|
141
|
+
return statSync(join(nestedReposDir, entry)).isDirectory();
|
|
142
|
+
} catch {
|
|
143
|
+
return false;
|
|
144
|
+
}
|
|
145
|
+
});
|
|
146
|
+
if (found.length > 0) {
|
|
147
|
+
repos = found;
|
|
148
|
+
discovered = true;
|
|
149
|
+
skipped.push({
|
|
150
|
+
step: 'discovery',
|
|
151
|
+
reason: `Tracker missing repos; discovered ${found.length} from ${nestedReposDir}: ${found.join(', ')}`,
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
} catch (err) {
|
|
155
|
+
skipped.push({ step: 'discovery', reason: `Failed to list ${nestedReposDir}: ${err.message}` });
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
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
|
+
|
|
207
|
+
// If branch is missing, ask the workspace worktree itself.
|
|
208
|
+
if (!branch && existsSync(wsWorktree)) {
|
|
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`);
|
|
239
|
+
}
|
|
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`);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
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
|
+
|
|
284
|
+
// === Step 1: Remove each project worktree FIRST, from its project repo ===
|
|
285
|
+
for (const repo of repos) {
|
|
286
|
+
const projWorktree = join(wsWorktree, 'repos', repo);
|
|
287
|
+
const repoDir = join(reposDir, repo);
|
|
288
|
+
if (!existsSync(projWorktree)) {
|
|
289
|
+
skipped.push({ step: 'remove-project-worktree', repo, reason: `${projWorktree} does not exist` });
|
|
290
|
+
continue;
|
|
291
|
+
}
|
|
292
|
+
if (!existsSync(repoDir)) {
|
|
293
|
+
errors.push(`Cannot remove ${repo} worktree: source clone missing at ${repoDir}`);
|
|
294
|
+
continue;
|
|
295
|
+
}
|
|
296
|
+
const res = git(repoDir, ['worktree', 'remove', projWorktree, '--force']);
|
|
297
|
+
if (res.ok) {
|
|
298
|
+
removed.push(`project worktree ${repo}`);
|
|
299
|
+
} else {
|
|
300
|
+
errors.push(`Failed to remove ${repo} worktree: ${res.err || res.out}`);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
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
|
+
|
|
323
|
+
// === Step 2: Remove the workspace worktree AFTER project worktrees are gone ===
|
|
324
|
+
if (existsSync(wsWorktree)) {
|
|
325
|
+
const res = git(root, ['worktree', 'remove', wsWorktree, '--force']);
|
|
326
|
+
if (res.ok) {
|
|
327
|
+
removed.push('workspace worktree');
|
|
328
|
+
} else {
|
|
329
|
+
errors.push(`Failed to remove workspace worktree: ${res.err || res.out}`);
|
|
330
|
+
}
|
|
331
|
+
} else {
|
|
332
|
+
skipped.push({ step: 'remove-workspace-worktree', reason: `${wsWorktree} does not exist` });
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// === Step 3: Prune each project repo to mop up orphans ===
|
|
336
|
+
for (const repo of repos) {
|
|
337
|
+
const repoDir = join(reposDir, repo);
|
|
338
|
+
if (!existsSync(repoDir)) continue;
|
|
339
|
+
const res = git(repoDir, ['worktree', 'prune']);
|
|
340
|
+
if (!res.ok) {
|
|
341
|
+
// Prune is a safety net, but if it fails on a repo we touched, surface
|
|
342
|
+
// it — verification below will catch leftover orphans either way.
|
|
343
|
+
errors.push(`Prune failed in ${repo}: ${res.err || res.out}`);
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// === Step 4: Delete local branches ===
|
|
348
|
+
if (branch) {
|
|
349
|
+
for (const repo of repos) {
|
|
350
|
+
const repoDir = join(reposDir, repo);
|
|
351
|
+
if (!existsSync(repoDir)) continue;
|
|
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}`);
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
// Same for the workspace repo (root).
|
|
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}`);
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// === Post-verification: success means VERIFIED, not "no try/catch threw" ===
|
|
371
|
+
//
|
|
372
|
+
// Without these checks, an empty repos list (the gh:119 root cause) lets
|
|
373
|
+
// every silent skip add up to a "success" output while leaving orphans
|
|
374
|
+
// behind. The verification turns silent skips into honest errors.
|
|
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
|
+
|
|
394
|
+
if (existsSync(sessionFolder)) {
|
|
395
|
+
errors.push(`Session folder still present after cleanup: ${sessionFolder}`);
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
const wsPath = wsWorktree; // canonical path the worktree had
|
|
399
|
+
for (const repo of repos) {
|
|
400
|
+
const repoDir = join(reposDir, repo);
|
|
401
|
+
if (!existsSync(repoDir)) continue;
|
|
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}`);
|
|
405
|
+
continue;
|
|
406
|
+
}
|
|
407
|
+
const wtList = listRes.out;
|
|
408
|
+
if (wtList.includes('prunable')) {
|
|
409
|
+
errors.push(`Prunable worktree record remains in ${repo} after cleanup (gh:119 symptom)`);
|
|
410
|
+
}
|
|
411
|
+
if (wtList.includes(wsPath)) {
|
|
412
|
+
errors.push(`${repo} still has a worktree record referencing the session path`);
|
|
413
|
+
}
|
|
414
|
+
if (branch) {
|
|
415
|
+
const stillRes = git(repoDir, ['branch', '--list', branch]);
|
|
416
|
+
if (stillRes.ok && stillRes.out.trim() !== '') {
|
|
417
|
+
errors.push(`Branch ${branch} still present in ${repo} after cleanup`);
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
if (branch) {
|
|
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`);
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
console.log(JSON.stringify({
|
|
430
|
+
success: errors.length === 0,
|
|
431
|
+
removed,
|
|
432
|
+
skipped: skipped.length > 0 ? skipped : undefined,
|
|
433
|
+
errors: errors.length > 0 ? errors : undefined,
|
|
434
|
+
}));
|
|
435
|
+
|
|
436
|
+
if (errors.length > 0) process.exit(1);
|