@infinitedusky/indusk-mcp 1.36.2 → 1.37.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/admin/.next/BUILD_ID +1 -1
- package/admin/.next/build-manifest.json +3 -3
- package/admin/.next/fallback-build-manifest.json +3 -3
- package/admin/.next/prerender-manifest.json +3 -3
- package/admin/.next/server/app/_global-error.html +1 -1
- package/admin/.next/server/app/_global-error.rsc +1 -1
- package/admin/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +1 -1
- package/admin/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
- package/admin/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
- package/admin/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
- package/admin/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
- package/admin/.next/server/chunks/ssr/[root-of-the-server]__0p.51_4._.js.map +1 -1
- package/admin/.next/server/middleware-build-manifest.js +3 -3
- package/admin/.next/server/pages/500.html +1 -1
- package/admin/.next/server/server-reference-manifest.js +1 -1
- package/admin/.next/server/server-reference-manifest.json +1 -1
- package/admin/.next/trace +1 -1
- package/admin/.next/trace-build +1 -1
- package/dist/bin/cli.js +42 -4
- package/dist/bin/commands/init.js +42 -10
- package/dist/bin/commands/telemetry.js +7 -1
- package/dist/bin/commands/update.js +104 -2
- package/dist/bin/commands/workbench.d.ts +76 -0
- package/dist/bin/commands/workbench.js +438 -0
- package/dist/bin/commands/worktree.d.ts +22 -6
- package/dist/bin/commands/worktree.js +154 -111
- package/dist/lib/config.d.ts +59 -0
- package/dist/lib/hook-migration.d.ts +42 -0
- package/dist/lib/hook-migration.js +94 -0
- package/dist/lib/path-segment.d.ts +23 -0
- package/dist/lib/path-segment.js +41 -0
- package/dist/lib/plan-parser.js +5 -13
- package/dist/lib/stray-state-audit.d.ts +3 -2
- package/dist/lib/stray-state-audit.js +14 -20
- package/dist/lib/telemetry/daemon.d.ts +18 -0
- package/dist/lib/telemetry/daemon.js +18 -2
- package/dist/lib/verify/git.d.ts +11 -4
- package/dist/lib/verify/git.js +11 -4
- package/dist/lib/verify/roots.d.ts +40 -0
- package/dist/lib/verify/roots.js +54 -0
- package/dist/lib/verify/verify.js +17 -2
- package/dist/lib/worktree/layout.d.ts +56 -0
- package/dist/lib/worktree/layout.js +136 -0
- package/dist/lib/worktree/repos.d.ts +46 -0
- package/dist/lib/worktree/repos.js +100 -0
- package/dist/lib/worktree/shareable.d.ts +47 -0
- package/dist/lib/worktree/shareable.js +301 -0
- package/dist/lib/worktree/sync.d.ts +62 -0
- package/dist/lib/worktree/sync.js +174 -0
- package/extensions/worktree/hooks/on_enable.sh +17 -10
- package/extensions/worktree/scripts/lib/workbench-helpers.sh +84 -0
- package/extensions/worktree/scripts/preflight.sh +4 -8
- package/extensions/worktree/scripts/refresh-worktree.sh +4 -8
- package/extensions/worktree/scripts/setup-worktree.sh +40 -16
- package/extensions/worktree/skill.md +8 -6
- package/hooks/_hook-paths.js +78 -15
- package/hooks/workbench-sync.js +119 -0
- package/package.json +109 -109
- package/skills/catchup.md +17 -0
- /package/admin/.next/static/{b30fOo0tUhHOxiaUT2sKQ → pg-wvx75mxUJvPhSjSoUe}/_buildManifest.js +0 -0
- /package/admin/.next/static/{b30fOo0tUhHOxiaUT2sKQ → pg-wvx75mxUJvPhSjSoUe}/_clientMiddlewareManifest.js +0 -0
- /package/admin/.next/static/{b30fOo0tUhHOxiaUT2sKQ → pg-wvx75mxUJvPhSjSoUe}/_ssgManifest.js +0 -0
|
@@ -0,0 +1,438 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { join, resolve } from "node:path";
|
|
5
|
+
import { linkTrunk, listWorkbenchSubdirs, worktreeOwner } from "../../lib/worktree/layout.js";
|
|
6
|
+
import { isWorkbench, readSiblingParent, readWorkbenchRepos, repoDir, } from "../../lib/worktree/repos.js";
|
|
7
|
+
import { allLocationsDeclared, ensureShareableScaffolding, missingIgnoreRules, topUpManagedIgnore, untrackNowIgnored, } from "../../lib/worktree/shareable.js";
|
|
8
|
+
import { ensureContextRepo, repoPublishState, syncWorkbench } from "../../lib/worktree/sync.js";
|
|
9
|
+
/** Files a restore can never supply. Printed every run, not only on failure. */
|
|
10
|
+
const OUT_OF_BAND = [
|
|
11
|
+
"env/*.env and per-app .env.<profile> pulls",
|
|
12
|
+
".indusk/extensions/doppler/.env (the service token)",
|
|
13
|
+
"any repo-local config (e.g. a database config.sh)",
|
|
14
|
+
"SSH host aliases your remotes depend on (e.g. `github-<org>` in ~/.ssh/config)",
|
|
15
|
+
];
|
|
16
|
+
function git(args, cwd) {
|
|
17
|
+
const r = spawnSync("git", args, { cwd, encoding: "utf-8" });
|
|
18
|
+
return { ok: r.status === 0, stderr: r.stderr ?? "" };
|
|
19
|
+
}
|
|
20
|
+
function expandHome(p) {
|
|
21
|
+
return p.startsWith("~/") ? join(homedir(), p.slice(2)) : p;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Where the sibling clones live.
|
|
25
|
+
*
|
|
26
|
+
* `sibling_parent` is an ABSOLUTE path committed to a SHARED context repo,
|
|
27
|
+
* which is a contradiction this plan has to absorb rather than inherit. One
|
|
28
|
+
* real workbench's committed value was another machine's home directory, so
|
|
29
|
+
* on this machine it named a directory that did not exist and every repo read
|
|
30
|
+
* as missing. No fixture catches this — a fixture writes its own tmpdir there.
|
|
31
|
+
*
|
|
32
|
+
* The fallback is the workbench's PARENT, which is not a guess: `indusk setup`
|
|
33
|
+
* builds exactly `<parent>/<repo>` beside `<parent>/<repo>-workbench`, so the
|
|
34
|
+
* parent IS the sibling directory. A declared path that exists still wins, so
|
|
35
|
+
* nothing changes for a workbench sitting on the machine that wrote it.
|
|
36
|
+
*/
|
|
37
|
+
function resolveSiblingParent(projectRoot) {
|
|
38
|
+
const parent = resolve(projectRoot, "..");
|
|
39
|
+
const declared = readSiblingParent(projectRoot);
|
|
40
|
+
if (!declared)
|
|
41
|
+
return { siblingParent: parent };
|
|
42
|
+
const expanded = resolve(expandHome(declared));
|
|
43
|
+
if (existsSync(expanded))
|
|
44
|
+
return { siblingParent: expanded };
|
|
45
|
+
return {
|
|
46
|
+
siblingParent: parent,
|
|
47
|
+
note: `Note: worktree.sibling_parent points at ${expanded}, which does not exist here — ` +
|
|
48
|
+
`it is an absolute path from whichever machine wrote it.\n` +
|
|
49
|
+
` Using this workbench's parent instead: ${parent}`,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Refuse when a workbench's own ignore file cannot carry the sharing contract.
|
|
54
|
+
*
|
|
55
|
+
* Found by pointing the tool at a real pre-existing workbench: scaffolding only
|
|
56
|
+
* TOPS UP an existing `.gitignore`, so one written before this plan never
|
|
57
|
+
* receives the deny-by-default rule — and `sync` then commits worktree
|
|
58
|
+
* contents. On a real workbench that is dozens of checkouts of someone else's
|
|
59
|
+
* repo entering a shared context repo.
|
|
60
|
+
*
|
|
61
|
+
* **Refuse rather than rewrite.** Appending `/*` plus an allow-list to a file
|
|
62
|
+
* somebody else wrote inverts its meaning: every root entry becomes denied
|
|
63
|
+
* unless listed. Given what is at stake, a loud stop beats an opinionated
|
|
64
|
+
* rewrite of a human's decision.
|
|
65
|
+
*
|
|
66
|
+
* `missingIgnoreRules` has existed and been correct since Phase 4 — it was
|
|
67
|
+
* simply never consulted at the moment it mattered.
|
|
68
|
+
*/
|
|
69
|
+
function refuseIfIgnoreCannotHold(projectRoot, repos, skip) {
|
|
70
|
+
if (skip)
|
|
71
|
+
return;
|
|
72
|
+
// A declared layout names its worktree directories exactly, so no
|
|
73
|
+
// deny-by-default rule is required and nothing to refuse.
|
|
74
|
+
if (allLocationsDeclared(repos))
|
|
75
|
+
return;
|
|
76
|
+
// InDusk wrote this file, so InDusk may extend it. Every freshly created
|
|
77
|
+
// workbench lands here — `init` scaffolds a `.gitignore`, so without this
|
|
78
|
+
// the refusal fires on the product's own output.
|
|
79
|
+
if (topUpManagedIgnore(projectRoot)) {
|
|
80
|
+
console.info("Added the workbench rules to the InDusk-managed .gitignore.");
|
|
81
|
+
}
|
|
82
|
+
const gaps = missingIgnoreRules(projectRoot);
|
|
83
|
+
if (gaps.length === 0)
|
|
84
|
+
return;
|
|
85
|
+
console.error("Error: this workbench's .gitignore cannot keep worktrees out of the shared remote.");
|
|
86
|
+
console.error("");
|
|
87
|
+
for (const gap of gaps)
|
|
88
|
+
console.error(` missing: ${gap}`);
|
|
89
|
+
console.error("");
|
|
90
|
+
console.error("Worktree directories are created at runtime, so they cannot be named in advance —");
|
|
91
|
+
console.error("a flat workbench needs the deny-by-default rule to keep them out.");
|
|
92
|
+
console.error("");
|
|
93
|
+
console.error("Fix it one of these ways:");
|
|
94
|
+
console.error(" - declare where worktrees live (worktree.repos[].worktrees), which names them precisely");
|
|
95
|
+
console.error(" - add the missing rule(s) to .gitignore yourself");
|
|
96
|
+
console.error(" - re-run with --no-ignore-check to proceed anyway");
|
|
97
|
+
process.exit(1);
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Phrase one repo's outcome.
|
|
101
|
+
*
|
|
102
|
+
* Separated from the loop that prints it so the four cases can be read — and
|
|
103
|
+
* tested — without spawning the CLI. The loop decides what happened; this says
|
|
104
|
+
* it. They are two jobs, and the four-way ternary that used to sit inline made
|
|
105
|
+
* that hard to see.
|
|
106
|
+
*/
|
|
107
|
+
export function restoreLine(repo, status, siblingParent) {
|
|
108
|
+
// `linkTrunk` deliberately refuses to replace a real directory sitting at
|
|
109
|
+
// the trunk path, so both `-unlinked` cases are correct behavior that the
|
|
110
|
+
// message must not describe as a link.
|
|
111
|
+
const unlinked = `a real directory occupies ${repoDir(repo)}/ in the workbench, so no trunk symlink was made — left as is`;
|
|
112
|
+
switch (status) {
|
|
113
|
+
case "cloned":
|
|
114
|
+
return `${repo.name} — cloned into ${siblingParent} and linked`;
|
|
115
|
+
case "cloned-unlinked":
|
|
116
|
+
return `${repo.name} — cloned into ${siblingParent}/${repo.name}, but ${unlinked}`;
|
|
117
|
+
case "present-unlinked":
|
|
118
|
+
return `${repo.name} — already present, but ${unlinked}`;
|
|
119
|
+
case "present":
|
|
120
|
+
return `${repo.name} — already present, trunk linked`;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
function restoreOne(repo, workbenchRoot, siblingParent) {
|
|
124
|
+
const target = join(siblingParent, repo.name);
|
|
125
|
+
if (existsSync(join(target, ".git"))) {
|
|
126
|
+
const linked = linkTrunk(workbenchRoot, repoDir(repo), target);
|
|
127
|
+
return { status: linked ? "present" : "present-unlinked" };
|
|
128
|
+
}
|
|
129
|
+
// Declared but unrestorable. Reported by name — a repo silently absent from
|
|
130
|
+
// a "successful" restore is the thing A12 exists to forbid.
|
|
131
|
+
if (!repo.remote) {
|
|
132
|
+
return {
|
|
133
|
+
status: "present",
|
|
134
|
+
failure: {
|
|
135
|
+
repo: repo.name,
|
|
136
|
+
reason: "declared with no `remote`, and not present on disk — add a remote to .indusk/config.json or clone it yourself",
|
|
137
|
+
},
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
mkdirSync(siblingParent, { recursive: true });
|
|
141
|
+
const { ok, stderr } = git(["clone", "--quiet", repo.remote, target], siblingParent);
|
|
142
|
+
if (!ok) {
|
|
143
|
+
return {
|
|
144
|
+
status: "present",
|
|
145
|
+
failure: {
|
|
146
|
+
repo: repo.name,
|
|
147
|
+
reason: `clone failed from ${repo.remote}${stderr.trim() ? ` — ${stderr.trim().split("\n")[0]}` : ""}`,
|
|
148
|
+
},
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
// By declared path, and reporting what the link attempt actually did —
|
|
152
|
+
// the clone branch had BOTH bugs the present branch had: it resolved by
|
|
153
|
+
// name rather than declared path, and it discarded the result.
|
|
154
|
+
const linked = linkTrunk(workbenchRoot, repoDir(repo), target);
|
|
155
|
+
return { status: linked ? "cloned" : "cloned-unlinked" };
|
|
156
|
+
}
|
|
157
|
+
export function workbenchRestore(projectRoot, opts = {}) {
|
|
158
|
+
const repos = readWorkbenchRepos(projectRoot);
|
|
159
|
+
if (!isWorkbench(projectRoot) || repos.length === 0) {
|
|
160
|
+
console.error('Error: this project is not a workbench (needs worktree.shape="workbench" and worktree.repos[] in .indusk/config.json).');
|
|
161
|
+
process.exit(1);
|
|
162
|
+
}
|
|
163
|
+
const { siblingParent, note } = resolveSiblingParent(projectRoot);
|
|
164
|
+
if (note)
|
|
165
|
+
console.info(note);
|
|
166
|
+
console.info(`Workbench: ${projectRoot}`);
|
|
167
|
+
console.info(`Repos (${repos.length}): ${repos.map((r) => r.name).join(", ")}`);
|
|
168
|
+
// Scaffolded here rather than at git-init time so a workbench that was
|
|
169
|
+
// made shareable by hand (the POC's path) still gets the rules, and so the
|
|
170
|
+
// ignore file exists BEFORE anything can be committed. Never overwrites.
|
|
171
|
+
const scaffold = ensureShareableScaffolding(projectRoot, repos);
|
|
172
|
+
if (scaffold.created.length > 0) {
|
|
173
|
+
console.info(`Scaffolded: ${scaffold.created.join(", ")}`);
|
|
174
|
+
}
|
|
175
|
+
// The context repo is created lazily, here and in `sync`, rather than
|
|
176
|
+
// demanded up front — a remote is a decision the developer makes, and
|
|
177
|
+
// local commits are useful before one exists. `git init` with no remote is
|
|
178
|
+
// a complete, working state, not a half-configured one.
|
|
179
|
+
if (ensureContextRepo(projectRoot)) {
|
|
180
|
+
console.info("Initialized the workbench context repo (add a remote to share it).");
|
|
181
|
+
}
|
|
182
|
+
refuseIfIgnoreCannotHold(projectRoot, repos, opts.noIgnoreCheck === true);
|
|
183
|
+
// Ignoring a path does not untrack it. A workbench git-initialized before
|
|
184
|
+
// these rules existed keeps publishing its symlinks and secrets while
|
|
185
|
+
// `git status` looks clean.
|
|
186
|
+
// NAMED, never counted. This drops files out of a SHARED repo's index —
|
|
187
|
+
// a workbench's own rules decide which, and "untracked 3 paths" gives a
|
|
188
|
+
// reader no way to notice that one of them was something they wanted.
|
|
189
|
+
// (Found on a real workbench: `.mcp.json` was both tracked AND ignored,
|
|
190
|
+
// so restore untracked it. Correct per their rule, surprising in silence.)
|
|
191
|
+
const untracked = untrackNowIgnored(projectRoot);
|
|
192
|
+
if (untracked.length > 0) {
|
|
193
|
+
console.info(`Untracked ${untracked.length} now-ignored path(s) — index only, files kept on disk:`);
|
|
194
|
+
for (const path of untracked)
|
|
195
|
+
console.info(` - ${path}`);
|
|
196
|
+
}
|
|
197
|
+
console.info("");
|
|
198
|
+
// Every repo is attempted. A first failure must not decide the fate of the
|
|
199
|
+
// rest, or restoring an N-repo workbench becomes a lottery decided by
|
|
200
|
+
// declaration order.
|
|
201
|
+
const failures = [];
|
|
202
|
+
for (const repo of repos) {
|
|
203
|
+
const { status, failure } = restoreOne(repo, projectRoot, siblingParent);
|
|
204
|
+
if (failure) {
|
|
205
|
+
failures.push(failure);
|
|
206
|
+
console.error(` ✗ ${repo.name} — ${failure.reason}`);
|
|
207
|
+
continue;
|
|
208
|
+
}
|
|
209
|
+
console.info(` ✓ ${restoreLine(repo, status, siblingParent)}`);
|
|
210
|
+
}
|
|
211
|
+
if (opts.worktrees) {
|
|
212
|
+
console.info("");
|
|
213
|
+
console.info("Worktrees: none declared (a worktrees[] manifest is not read yet).");
|
|
214
|
+
}
|
|
215
|
+
console.info("");
|
|
216
|
+
console.info("Supply these out-of-band — restore cannot and never will:");
|
|
217
|
+
for (const item of OUT_OF_BAND)
|
|
218
|
+
console.info(` - ${item}`);
|
|
219
|
+
if (failures.length > 0) {
|
|
220
|
+
console.error("");
|
|
221
|
+
console.error(`Restore incomplete — ${failures.length} of ${repos.length} repo(s) unresolved:`);
|
|
222
|
+
for (const f of failures)
|
|
223
|
+
console.error(` - ${f.repo}: ${f.reason}`);
|
|
224
|
+
console.error("Fix the above and re-run `indusk workbench restore` — it is idempotent.");
|
|
225
|
+
process.exit(1);
|
|
226
|
+
}
|
|
227
|
+
console.info("");
|
|
228
|
+
console.info("Workbench restored. Next: supply the out-of-band set, then `indusk update`.");
|
|
229
|
+
process.exit(0);
|
|
230
|
+
}
|
|
231
|
+
/**
|
|
232
|
+
* `indusk workbench sync` — one sync point: commit, pull, push, blindly resolved.
|
|
233
|
+
*
|
|
234
|
+
* Exits 0 even when the remote is unreachable. The work is committed locally
|
|
235
|
+
* and goes out on the next sync; someone else's outage must never become your
|
|
236
|
+
* inability to work.
|
|
237
|
+
*/
|
|
238
|
+
export function workbenchSyncCommand(projectRoot, opts = {}) {
|
|
239
|
+
const repos = readWorkbenchRepos(projectRoot);
|
|
240
|
+
if (!isWorkbench(projectRoot) && repos.length === 0) {
|
|
241
|
+
console.error('Error: this project is not a workbench (needs worktree.shape="workbench" in .indusk/config.json).');
|
|
242
|
+
process.exit(1);
|
|
243
|
+
}
|
|
244
|
+
// The ignore rules must exist before anything is committed, or the first
|
|
245
|
+
// sync publishes exactly what they were written to keep out.
|
|
246
|
+
const scaffold = ensureShareableScaffolding(projectRoot, repos);
|
|
247
|
+
if (scaffold.created.length > 0)
|
|
248
|
+
console.info(`Scaffolded: ${scaffold.created.join(", ")}`);
|
|
249
|
+
refuseIfIgnoreCannotHold(projectRoot, repos, opts.noIgnoreCheck === true);
|
|
250
|
+
// Named here too, not only in `restore`. Sync runs unattended on a hook, so
|
|
251
|
+
// a silent index change is the one most likely to go unnoticed.
|
|
252
|
+
const dropped = untrackNowIgnored(projectRoot);
|
|
253
|
+
if (dropped.length > 0) {
|
|
254
|
+
console.info(`Untracked ${dropped.length} now-ignored path(s) — index only, files kept on disk:`);
|
|
255
|
+
for (const path of dropped)
|
|
256
|
+
console.info(` - ${path}`);
|
|
257
|
+
}
|
|
258
|
+
const result = syncWorkbench(projectRoot);
|
|
259
|
+
for (const note of result.notes)
|
|
260
|
+
console.info(note);
|
|
261
|
+
if (!result.committed && result.notes.length === 0)
|
|
262
|
+
console.info("Nothing to sync — tree is clean.");
|
|
263
|
+
console.info(`sync: committed=${result.committed} pulled=${result.pulled} pushed=${result.pushed}`);
|
|
264
|
+
process.exit(0);
|
|
265
|
+
}
|
|
266
|
+
/**
|
|
267
|
+
* `indusk workbench status` — is each declared repo materialized, and has its
|
|
268
|
+
* work actually left this machine?
|
|
269
|
+
*/
|
|
270
|
+
export function workbenchStatusCommand(projectRoot) {
|
|
271
|
+
const repos = readWorkbenchRepos(projectRoot);
|
|
272
|
+
if (repos.length === 0) {
|
|
273
|
+
console.error("Error: this project declares no repos (worktree.repos[] in .indusk/config.json).");
|
|
274
|
+
process.exit(1);
|
|
275
|
+
}
|
|
276
|
+
const { siblingParent } = resolveSiblingParent(projectRoot);
|
|
277
|
+
console.info(`Workbench: ${projectRoot}`);
|
|
278
|
+
console.info("");
|
|
279
|
+
for (const repo of repos) {
|
|
280
|
+
// By DECLARED path, like every other consumer. This was the one place
|
|
281
|
+
// still deriving a location from a name.
|
|
282
|
+
const st = repoPublishState(siblingParent, repoDir(repo));
|
|
283
|
+
if (!st.present) {
|
|
284
|
+
console.info(` ${repo.name}: not materialized — run \`indusk workbench restore\``);
|
|
285
|
+
continue;
|
|
286
|
+
}
|
|
287
|
+
if (!st.hasRemote) {
|
|
288
|
+
console.info(` ${repo.name}: present, no remote configured (nothing to publish to)`);
|
|
289
|
+
continue;
|
|
290
|
+
}
|
|
291
|
+
if (!st.published) {
|
|
292
|
+
console.info(` ${repo.name}: has a remote, but this branch has NEVER BEEN PUSHED — none of its work is visible to anyone else`);
|
|
293
|
+
continue;
|
|
294
|
+
}
|
|
295
|
+
console.info(st.ahead > 0
|
|
296
|
+
? ` ${repo.name}: ${st.ahead} commit(s) ahead of its remote — NOT PUSHED, so a teammate pulling this workbench cannot see that work yet`
|
|
297
|
+
: ` ${repo.name}: in sync with its remote`);
|
|
298
|
+
}
|
|
299
|
+
process.exit(0);
|
|
300
|
+
}
|
|
301
|
+
/**
|
|
302
|
+
* `indusk workbench migrate-layout` — move a flat workbench into declared layout.
|
|
303
|
+
*
|
|
304
|
+
* DRY-RUN BY DEFAULT. A command that relocates directories shows its plan
|
|
305
|
+
* before doing anything; `--apply` performs it.
|
|
306
|
+
*
|
|
307
|
+
* Uses `git worktree move`, never a manual rename. A git worktree is two
|
|
308
|
+
* cross-references — the worktree's `.git` file and the repo's
|
|
309
|
+
* `.git/worktrees/<name>/gitdir` pointing back — and moving the directory
|
|
310
|
+
* without repairing both leaves something that looks right and is broken. That
|
|
311
|
+
* is the only failure of this command that would matter, so it is handed to
|
|
312
|
+
* the tool that knows.
|
|
313
|
+
*
|
|
314
|
+
* Wrapped repos are never committed to or modified beyond the worktree
|
|
315
|
+
* bookkeeping: this moves worktrees, not product code.
|
|
316
|
+
*/
|
|
317
|
+
export function workbenchMigrateLayout(projectRoot, opts = {}) {
|
|
318
|
+
const repos = readWorkbenchRepos(projectRoot);
|
|
319
|
+
if (repos.length === 0) {
|
|
320
|
+
console.error("Error: this project declares no repos (worktree.repos[] in .indusk/config.json).");
|
|
321
|
+
process.exit(1);
|
|
322
|
+
}
|
|
323
|
+
const alreadyDeclared = repos.filter((r) => r.worktrees);
|
|
324
|
+
if (alreadyDeclared.length === repos.length) {
|
|
325
|
+
console.info("Every repo already declares a worktrees location — nothing to migrate.");
|
|
326
|
+
process.exit(0);
|
|
327
|
+
}
|
|
328
|
+
const repoPaths = new Map(repos.map((r) => [r.name, join(projectRoot, repoDir(r))]));
|
|
329
|
+
const occupied = new Set(repos.map((r) => repoDir(r)));
|
|
330
|
+
const loose = listWorkbenchSubdirs(projectRoot).filter((n) => !occupied.has(n));
|
|
331
|
+
const moves = [];
|
|
332
|
+
const unplaceable = [];
|
|
333
|
+
const unmovable = [];
|
|
334
|
+
for (const slug of loose) {
|
|
335
|
+
const owner = worktreeOwner(join(projectRoot, slug), repoPaths);
|
|
336
|
+
if (!owner) {
|
|
337
|
+
unplaceable.push(slug);
|
|
338
|
+
continue;
|
|
339
|
+
}
|
|
340
|
+
const dest = `${owner}-worktrees`;
|
|
341
|
+
// A worktree already NAMED `<repo>-worktrees` would be moved inside
|
|
342
|
+
// itself. git refuses that, but only at execution — so the plan would
|
|
343
|
+
// show an impossible move and then fail on `Invalid argument`, which
|
|
344
|
+
// tells the reader nothing. Catch it while planning.
|
|
345
|
+
if (slug === dest) {
|
|
346
|
+
unmovable.push(`${slug}: cannot move — the destination is inside itself`);
|
|
347
|
+
continue;
|
|
348
|
+
}
|
|
349
|
+
moves.push({
|
|
350
|
+
repo: owner,
|
|
351
|
+
slug,
|
|
352
|
+
from: join(projectRoot, slug),
|
|
353
|
+
to: join(projectRoot, dest, slug),
|
|
354
|
+
});
|
|
355
|
+
}
|
|
356
|
+
console.info(`Workbench: ${projectRoot}`);
|
|
357
|
+
console.info(opts.apply ? "Applying layout migration:" : "Dry run — would move:");
|
|
358
|
+
console.info("");
|
|
359
|
+
for (const m of moves) {
|
|
360
|
+
console.info(` ${m.slug} -> ${m.repo}-worktrees/${m.slug}`);
|
|
361
|
+
}
|
|
362
|
+
if (moves.length === 0)
|
|
363
|
+
console.info(" (no worktrees to move)");
|
|
364
|
+
if (unplaceable.length > 0) {
|
|
365
|
+
console.info("");
|
|
366
|
+
console.info("Left where they are — not resolvable to any declared repo:");
|
|
367
|
+
for (const slug of unplaceable)
|
|
368
|
+
console.info(` ${slug}`);
|
|
369
|
+
}
|
|
370
|
+
if (unmovable.length > 0) {
|
|
371
|
+
console.info("");
|
|
372
|
+
console.info("Skipped — cannot be moved:");
|
|
373
|
+
for (const why of unmovable)
|
|
374
|
+
console.info(` ${why}`);
|
|
375
|
+
}
|
|
376
|
+
if (!opts.apply) {
|
|
377
|
+
console.info("");
|
|
378
|
+
console.info("Nothing changed. Re-run with --apply to perform the migration.");
|
|
379
|
+
process.exit(0);
|
|
380
|
+
}
|
|
381
|
+
// Perform. Every failure is collected and named; a partial migration that
|
|
382
|
+
// exits 0 is the shape this plan has refused throughout.
|
|
383
|
+
const failures = [];
|
|
384
|
+
const migrated = new Set();
|
|
385
|
+
for (const m of moves) {
|
|
386
|
+
mkdirSync(join(projectRoot, `${m.repo}-worktrees`), { recursive: true });
|
|
387
|
+
const repoPath = repoPaths.get(m.repo);
|
|
388
|
+
const r = spawnSync("git", ["-C", repoPath ?? projectRoot, "worktree", "move", m.from, m.to], {
|
|
389
|
+
encoding: "utf-8",
|
|
390
|
+
});
|
|
391
|
+
if (r.status === 0) {
|
|
392
|
+
console.info(` ✓ ${m.slug}`);
|
|
393
|
+
migrated.add(m.repo);
|
|
394
|
+
}
|
|
395
|
+
else {
|
|
396
|
+
const why = (r.stderr ?? "").trim().split("\n")[0] ?? "unknown error";
|
|
397
|
+
console.error(` ✗ ${m.slug} — ${why}`);
|
|
398
|
+
failures.push(`${m.slug}: ${why}`);
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
// Declare the layout for every repo whose worktrees actually moved, plus
|
|
402
|
+
// any repo that had none — the declaration is what makes the ignore rule
|
|
403
|
+
// precise, and a repo with no worktrees still benefits.
|
|
404
|
+
const declared = declareWorktreeLocations(projectRoot, repos.filter((r) => !r.worktrees && (migrated.has(r.name) || !moves.some((m) => m.repo === r.name))));
|
|
405
|
+
if (declared.length > 0) {
|
|
406
|
+
console.info("");
|
|
407
|
+
console.info(`Declared worktrees location for: ${declared.join(", ")}`);
|
|
408
|
+
}
|
|
409
|
+
if (failures.length > 0) {
|
|
410
|
+
console.error("");
|
|
411
|
+
console.error(`Migration incomplete — ${failures.length} worktree(s) could not be moved:`);
|
|
412
|
+
for (const f of failures)
|
|
413
|
+
console.error(` - ${f}`);
|
|
414
|
+
console.error("Fix those and re-run — the command is safe to repeat.");
|
|
415
|
+
process.exit(1);
|
|
416
|
+
}
|
|
417
|
+
console.info("");
|
|
418
|
+
console.info("Layout migrated. `indusk worktree list` now groups by repo.");
|
|
419
|
+
process.exit(0);
|
|
420
|
+
}
|
|
421
|
+
/** Write `worktrees` for the given repos. Returns the names it declared. */
|
|
422
|
+
function declareWorktreeLocations(projectRoot, repos) {
|
|
423
|
+
if (repos.length === 0)
|
|
424
|
+
return [];
|
|
425
|
+
const cfgPath = join(projectRoot, ".indusk", "config.json");
|
|
426
|
+
const cfg = JSON.parse(readFileSync(cfgPath, "utf-8"));
|
|
427
|
+
const names = new Set(repos.map((r) => r.name));
|
|
428
|
+
const done = [];
|
|
429
|
+
for (const entry of cfg.worktree?.repos ?? []) {
|
|
430
|
+
if (names.has(entry.name) && !entry.worktrees) {
|
|
431
|
+
entry.worktrees = `${entry.name}-worktrees`;
|
|
432
|
+
done.push(entry.name);
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
if (done.length > 0)
|
|
436
|
+
writeFileSync(cfgPath, `${JSON.stringify(cfg, null, 2)}\n`);
|
|
437
|
+
return done;
|
|
438
|
+
}
|
|
@@ -5,14 +5,30 @@
|
|
|
5
5
|
* from the user's invocation dir).
|
|
6
6
|
*/
|
|
7
7
|
export declare function worktreeOnEnable(): void;
|
|
8
|
-
|
|
8
|
+
/**
|
|
9
|
+
* `indusk worktree create [repo] <slug> [base-branch]`.
|
|
10
|
+
*
|
|
11
|
+
* The repo argument returns from the pre-v1 multi-repo design. Disambiguated
|
|
12
|
+
* against the DECLARED SET rather than by arity: a leading token is a repo
|
|
13
|
+
* only if the workbench actually declares it, so `create my-feature` in a
|
|
14
|
+
* one-repo workbench still means what it always meant, and `create alpha
|
|
15
|
+
* my-feature` means alpha. Guessing by position would silently reinterpret
|
|
16
|
+
* every existing single-repo invocation whose slug happened to arrive first.
|
|
17
|
+
*
|
|
18
|
+
* When the repo cannot be determined here the args pass through untouched and
|
|
19
|
+
* `_resolve_workbench_repo` in the shell refuses, naming the candidates —
|
|
20
|
+
* one refusal, in one place, rather than two that can disagree.
|
|
21
|
+
*/
|
|
22
|
+
export declare function worktreeCreate(args: string[], projectRoot?: string): never;
|
|
9
23
|
export declare function worktreeRefresh(slug: string): never;
|
|
10
24
|
export declare function worktreePreflight(slug: string, baseBranch?: string): never;
|
|
11
25
|
/**
|
|
12
|
-
* `indusk worktree list` —
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
26
|
+
* `indusk worktree list` — the workbench's current state, one block per
|
|
27
|
+
* declared repo.
|
|
28
|
+
*
|
|
29
|
+
* N repos rather than one wrapped repo: each declared repo gets its own trunk
|
|
30
|
+
* line, its own config badge, and its own worktrees. Attribution matters more
|
|
31
|
+
* than layout here — a reader has to be able to tell "beta has no worktrees"
|
|
32
|
+
* from "beta's worktrees are listed under alpha".
|
|
17
33
|
*/
|
|
18
34
|
export declare function worktreeList(projectRoot: string): void;
|