magus-cli 7.9.1 → 7.10.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +4 -4
- package/src/__tests__/blind/c15-probe-ownership.test.ts +3 -3
- package/src/__tests__/blind/plugin-deps/doctor.test.ts +1 -0
- package/src/__tests__/cli-tools-adapter.test.ts +25 -6
- package/src/__tests__/plugin-mcp-source-shapes.test.ts +250 -0
- package/src/__tests__/tool-probe.test.ts +21 -13
- package/src/__tests__/update-plan.test.ts +19 -3
- package/src/__tests__/worktree-profiles.test.ts +583 -0
- package/src/cli/doctor.ts +24 -2
- package/src/cli/update-view.ts +72 -0
- package/src/cli/update.ts +21 -0
- package/src/cli/worktrees.ts +32 -0
- package/src/services/git-worktree.ts +41 -0
- package/src/services/plugin-mcp-config.ts +3 -1
- package/src/services/tool-probe.ts +41 -7
- package/src/services/update-plan.ts +9 -4
- package/src/services/worktree-profiles.ts +181 -0
- package/src/ui/App.tsx +32 -1
- package/src/ui/adapters/cliToolsAdapter.ts +15 -6
- package/src/ui/adapters/startupReports.ts +22 -2
- package/src/ui/renderers/cliToolRenderers.tsx +13 -2
|
@@ -0,0 +1,583 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The other working trees of a repository that carry a magus manifest but have
|
|
3
|
+
* no profile in effect — found read-only, then set up with the declared profile.
|
|
4
|
+
*
|
|
5
|
+
* Written from the spec alone (docs/plans/2026-10-09-worktree-profile-setup.md,
|
|
6
|
+
* "Fix 2"), never from the implementation. Exercised against real git
|
|
7
|
+
* repositories with real linked worktrees: the function exists to agree with
|
|
8
|
+
* git about which working trees there are, and a stubbed `git worktree list`
|
|
9
|
+
* would confirm a wrong parse just as happily as a right one.
|
|
10
|
+
*
|
|
11
|
+
* Every test builds its own repository under the OS temp dir and runs with HOME
|
|
12
|
+
* (and the git/XDG config that hangs off it) pointed at a scratch folder, so
|
|
13
|
+
* nothing here reads or writes the real ~/.claude.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
17
|
+
import { execFileSync } from "node:child_process";
|
|
18
|
+
import os from "node:os";
|
|
19
|
+
import path from "node:path";
|
|
20
|
+
import fs from "fs-extra";
|
|
21
|
+
import { clearWorktreeCache } from "../services/git-worktree.js";
|
|
22
|
+
import { detectProfileStartupNeed } from "../services/profile-startup.js";
|
|
23
|
+
import {
|
|
24
|
+
findUnsetWorktrees,
|
|
25
|
+
setUpWorktrees,
|
|
26
|
+
} from "../services/worktree-profiles.js";
|
|
27
|
+
import type { ProfileManifest } from "../types/index.js";
|
|
28
|
+
|
|
29
|
+
const TIMEOUT = 30_000;
|
|
30
|
+
|
|
31
|
+
// GUESS: a profile with an empty `plugins` map and no marketplaces is a valid
|
|
32
|
+
// manifest entry, and activating it renders the project's files without any
|
|
33
|
+
// install, CLI call or network. The plan asks for "zero or few plugins so
|
|
34
|
+
// activation needs no network"; zero is the surest way to get that.
|
|
35
|
+
const TEAM: ProfileManifest = {
|
|
36
|
+
version: 2,
|
|
37
|
+
profiles: {
|
|
38
|
+
team: { name: "Team", plugins: {} },
|
|
39
|
+
solo: { name: "Solo", plugins: {} },
|
|
40
|
+
},
|
|
41
|
+
default: "team",
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
/** Same profiles, but this branch declares a different default. */
|
|
45
|
+
const SOLO_DEFAULT: ProfileManifest = { ...TEAM, default: "solo" };
|
|
46
|
+
|
|
47
|
+
/** Profiles, but no declared default: a human has to choose. */
|
|
48
|
+
const NO_DEFAULT: ProfileManifest = {
|
|
49
|
+
version: 2,
|
|
50
|
+
profiles: TEAM.profiles,
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
// The files magus generates are git-ignored, exactly as in a real profiled
|
|
54
|
+
// project — which is why git never hands them to a new worktree.
|
|
55
|
+
const GITIGNORE = [
|
|
56
|
+
".claude/settings.json",
|
|
57
|
+
".mcp.json",
|
|
58
|
+
".claude/profiles/",
|
|
59
|
+
"",
|
|
60
|
+
].join("\n");
|
|
61
|
+
|
|
62
|
+
// Isolate from the operator's git config: signing, a default branch name or a
|
|
63
|
+
// global excludes file would otherwise leak into these repositories.
|
|
64
|
+
const GIT_CFG = [
|
|
65
|
+
"-c",
|
|
66
|
+
"user.name=test",
|
|
67
|
+
"-c",
|
|
68
|
+
"user.email=test@example.com",
|
|
69
|
+
"-c",
|
|
70
|
+
"commit.gpgsign=false",
|
|
71
|
+
"-c",
|
|
72
|
+
"init.defaultBranch=main",
|
|
73
|
+
];
|
|
74
|
+
|
|
75
|
+
const git = (cwd: string, ...args: string[]): string =>
|
|
76
|
+
execFileSync("git", [...GIT_CFG, ...args], {
|
|
77
|
+
cwd,
|
|
78
|
+
encoding: "utf8",
|
|
79
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
80
|
+
}).trim();
|
|
81
|
+
|
|
82
|
+
// ─── Environment isolation ───────────────────────────────────────────────────
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Variables that would point git (ours or the module's) somewhere other than
|
|
86
|
+
* the scratch repository — set when the suite runs from inside a git hook — or
|
|
87
|
+
* that would let either read the operator's configuration.
|
|
88
|
+
*/
|
|
89
|
+
const ENV_KEYS = [
|
|
90
|
+
"HOME",
|
|
91
|
+
"CLAUDE_CONFIG_DIR",
|
|
92
|
+
"XDG_CONFIG_HOME",
|
|
93
|
+
"XDG_CACHE_HOME",
|
|
94
|
+
"GIT_CONFIG_NOSYSTEM",
|
|
95
|
+
"GIT_CEILING_DIRECTORIES",
|
|
96
|
+
"GIT_DIR",
|
|
97
|
+
"GIT_WORK_TREE",
|
|
98
|
+
"GIT_INDEX_FILE",
|
|
99
|
+
"GIT_COMMON_DIR",
|
|
100
|
+
"GIT_OBJECT_DIRECTORY",
|
|
101
|
+
"GIT_PREFIX",
|
|
102
|
+
] as const;
|
|
103
|
+
|
|
104
|
+
let savedEnv: Record<string, string | undefined> = {};
|
|
105
|
+
|
|
106
|
+
function setEnv(key: string, value: string | undefined): void {
|
|
107
|
+
if (value === undefined) {
|
|
108
|
+
delete process.env[key];
|
|
109
|
+
} else {
|
|
110
|
+
process.env[key] = value;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// ─── Fixture ─────────────────────────────────────────────────────────────────
|
|
115
|
+
|
|
116
|
+
interface Fixture {
|
|
117
|
+
/** realpath'd scratch root: on macOS os.tmpdir() is a symlink, git reports real paths. */
|
|
118
|
+
base: string;
|
|
119
|
+
home: string;
|
|
120
|
+
/** The main working tree, folder "repo", branch main, TEAM manifest. */
|
|
121
|
+
main: string;
|
|
122
|
+
/** Linked worktree "alpha", TEAM manifest. */
|
|
123
|
+
alpha: string;
|
|
124
|
+
/** Linked worktree "zeta", TEAM manifest. */
|
|
125
|
+
zeta: string;
|
|
126
|
+
/** Paths whose permissions a test removed; restored before cleanup. */
|
|
127
|
+
locked: string[];
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
let fx: Fixture;
|
|
131
|
+
|
|
132
|
+
async function writeManifest(
|
|
133
|
+
tree: string,
|
|
134
|
+
manifest: ProfileManifest,
|
|
135
|
+
): Promise<void> {
|
|
136
|
+
await fs.outputFile(
|
|
137
|
+
path.join(tree, ".claude", "profiles.json"),
|
|
138
|
+
`${JSON.stringify(manifest, null, 2)}\n`,
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Add a linked worktree at `<base>/<name>` on a new branch.
|
|
144
|
+
*
|
|
145
|
+
* `from: "bare"` starts it at the commit before any manifest existed, so the
|
|
146
|
+
* branch is not a magus project. `manifest` commits a different manifest on
|
|
147
|
+
* that worktree's own branch.
|
|
148
|
+
*/
|
|
149
|
+
async function addWorktree(
|
|
150
|
+
name: string,
|
|
151
|
+
opts: { from?: "bare" | "main"; manifest?: ProfileManifest } = {},
|
|
152
|
+
): Promise<string> {
|
|
153
|
+
const at = path.join(fx.base, name);
|
|
154
|
+
const start = opts.from === "bare" ? "bare" : "main";
|
|
155
|
+
git(fx.main, "worktree", "add", "-q", "-b", `wt-${name}`, at, start);
|
|
156
|
+
if (opts.manifest) {
|
|
157
|
+
await writeManifest(at, opts.manifest);
|
|
158
|
+
git(at, "add", "-A");
|
|
159
|
+
git(at, "commit", "-qm", `manifest for ${name}`);
|
|
160
|
+
}
|
|
161
|
+
return at;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
async function buildFixture(): Promise<Fixture> {
|
|
165
|
+
const base = await fs.realpath(
|
|
166
|
+
await fs.mkdtemp(path.join(os.tmpdir(), "magus-wt-profiles-")),
|
|
167
|
+
);
|
|
168
|
+
const home = path.join(base, "home");
|
|
169
|
+
await fs.ensureDir(path.join(home, ".claude"));
|
|
170
|
+
await fs.ensureDir(path.join(home, ".config"));
|
|
171
|
+
|
|
172
|
+
savedEnv = Object.fromEntries(ENV_KEYS.map((k) => [k, process.env[k]]));
|
|
173
|
+
for (const key of ENV_KEYS) setEnv(key, undefined);
|
|
174
|
+
setEnv("HOME", home);
|
|
175
|
+
setEnv("CLAUDE_CONFIG_DIR", path.join(home, ".claude"));
|
|
176
|
+
setEnv("XDG_CONFIG_HOME", path.join(home, ".config"));
|
|
177
|
+
setEnv("XDG_CACHE_HOME", path.join(home, ".cache"));
|
|
178
|
+
setEnv("GIT_CONFIG_NOSYSTEM", "1");
|
|
179
|
+
|
|
180
|
+
const main = path.join(base, "repo");
|
|
181
|
+
await fs.ensureDir(main);
|
|
182
|
+
git(main, "init", "-q");
|
|
183
|
+
await fs.writeFile(path.join(main, "README.md"), "hello\n");
|
|
184
|
+
await fs.writeFile(path.join(main, ".gitignore"), GITIGNORE);
|
|
185
|
+
git(main, "add", "-A");
|
|
186
|
+
git(main, "commit", "-qm", "initial");
|
|
187
|
+
// A branch from before the project adopted magus profiles.
|
|
188
|
+
git(main, "branch", "bare");
|
|
189
|
+
|
|
190
|
+
await writeManifest(main, TEAM);
|
|
191
|
+
git(main, "add", "-A");
|
|
192
|
+
git(main, "commit", "-qm", "magus manifest");
|
|
193
|
+
|
|
194
|
+
fx = {
|
|
195
|
+
base,
|
|
196
|
+
home,
|
|
197
|
+
main,
|
|
198
|
+
alpha: "",
|
|
199
|
+
zeta: "",
|
|
200
|
+
locked: [],
|
|
201
|
+
};
|
|
202
|
+
// Created out of name order, so a sorted result is the module's doing.
|
|
203
|
+
fx.zeta = await addWorktree("zeta");
|
|
204
|
+
fx.alpha = await addWorktree("alpha");
|
|
205
|
+
return fx;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
beforeEach(async () => {
|
|
209
|
+
clearWorktreeCache();
|
|
210
|
+
await buildFixture();
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
afterEach(async () => {
|
|
214
|
+
for (const p of fx.locked) {
|
|
215
|
+
try {
|
|
216
|
+
await fs.chmod(p, 0o755);
|
|
217
|
+
} catch {
|
|
218
|
+
// already gone
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
for (const [key, value] of Object.entries(savedEnv)) setEnv(key, value);
|
|
222
|
+
await fs.remove(fx.base);
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Every path under `root` with its type, size and mtime — directories too,
|
|
229
|
+
* since a created-then-removed file still moves its directory's mtime. lstat,
|
|
230
|
+
* so a symlink is recorded as itself and never followed out of the tree.
|
|
231
|
+
*/
|
|
232
|
+
async function snapshot(root: string): Promise<string[]> {
|
|
233
|
+
const out: string[] = [];
|
|
234
|
+
async function walk(dir: string): Promise<void> {
|
|
235
|
+
const entries = await fs.readdir(dir);
|
|
236
|
+
for (const entry of entries) {
|
|
237
|
+
const full = path.join(dir, entry);
|
|
238
|
+
const st = await fs.lstat(full);
|
|
239
|
+
const kind = st.isDirectory() ? "d" : st.isSymbolicLink() ? "l" : "f";
|
|
240
|
+
out.push(`${path.relative(root, full)} ${kind} ${st.size} ${st.mtimeMs}`);
|
|
241
|
+
if (st.isDirectory()) await walk(full);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
const st = await fs.lstat(root);
|
|
245
|
+
out.push(`. d ${st.size} ${st.mtimeMs}`);
|
|
246
|
+
await walk(root);
|
|
247
|
+
return out.sort();
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** A tree's snapshot without its `.git` entry (a linked worktree's is a file). */
|
|
251
|
+
async function snapshotTree(tree: string): Promise<string[]> {
|
|
252
|
+
return (await snapshot(tree)).filter(
|
|
253
|
+
(line) => !line.startsWith(".git ") && !line.startsWith(`.git${path.sep}`),
|
|
254
|
+
);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const names = (list: { name: string }[]): string[] => list.map((w) => w.name);
|
|
258
|
+
|
|
259
|
+
async function stateOf(tree: string): Promise<string> {
|
|
260
|
+
return (await detectProfileStartupNeed(tree)).state;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// ─── findUnsetWorktrees ──────────────────────────────────────────────────────
|
|
264
|
+
|
|
265
|
+
describe("findUnsetWorktrees", () => {
|
|
266
|
+
test(
|
|
267
|
+
"from the main tree: every linked worktree with a manifest and no profile, sorted by name, main excluded",
|
|
268
|
+
async () => {
|
|
269
|
+
const found = await findUnsetWorktrees(fx.main);
|
|
270
|
+
|
|
271
|
+
// zeta was created first; the order is the module's sort, not git's.
|
|
272
|
+
expect(names(found)).toEqual(["alpha", "zeta"]);
|
|
273
|
+
expect(names(found)).toEqual([...names(found)].sort());
|
|
274
|
+
expect(names(found)).not.toContain("repo");
|
|
275
|
+
|
|
276
|
+
const byName = new Map(found.map((w) => [w.name, w]));
|
|
277
|
+
for (const [name, tree] of [
|
|
278
|
+
["alpha", fx.alpha],
|
|
279
|
+
["zeta", fx.zeta],
|
|
280
|
+
] as const) {
|
|
281
|
+
const entry = byName.get(name);
|
|
282
|
+
expect(entry).toBeDefined();
|
|
283
|
+
if (!entry) continue;
|
|
284
|
+
expect(path.isAbsolute(entry.path)).toBe(true);
|
|
285
|
+
expect(entry.path).toBe(tree);
|
|
286
|
+
expect(entry.name).toBe(path.basename(tree));
|
|
287
|
+
expect(entry.state).not.toBe("ok");
|
|
288
|
+
// The state is detectProfileStartupNeed's, for THAT tree.
|
|
289
|
+
expect(entry.state).toBe(await stateOf(tree));
|
|
290
|
+
// A fresh worktree of a repo that declares its default: nothing to
|
|
291
|
+
// replace, so it can be set up without asking.
|
|
292
|
+
expect(entry.declaredDefault).toBe("team");
|
|
293
|
+
expect(entry.autoActivate).toBe(true);
|
|
294
|
+
}
|
|
295
|
+
},
|
|
296
|
+
TIMEOUT,
|
|
297
|
+
);
|
|
298
|
+
|
|
299
|
+
test(
|
|
300
|
+
"from a linked worktree: the unset main tree is listed, the caller's own tree is not",
|
|
301
|
+
async () => {
|
|
302
|
+
const found = await findUnsetWorktrees(fx.zeta);
|
|
303
|
+
|
|
304
|
+
// "repo" sorts after "alpha" though git lists the main tree first.
|
|
305
|
+
expect(names(found)).toEqual(["alpha", "repo"]);
|
|
306
|
+
expect(names(found)).not.toContain("zeta");
|
|
307
|
+
const mainEntry = found.find((w) => w.name === "repo");
|
|
308
|
+
expect(mainEntry?.path).toBe(fx.main);
|
|
309
|
+
expect(mainEntry?.state).not.toBe("ok");
|
|
310
|
+
},
|
|
311
|
+
TIMEOUT,
|
|
312
|
+
);
|
|
313
|
+
|
|
314
|
+
test(
|
|
315
|
+
"called from a subfolder, that folder's own working tree is still excluded",
|
|
316
|
+
async () => {
|
|
317
|
+
const sub = path.join(fx.main, "packages", "app");
|
|
318
|
+
await fs.ensureDir(sub);
|
|
319
|
+
|
|
320
|
+
const found = await findUnsetWorktrees(sub);
|
|
321
|
+
|
|
322
|
+
expect(names(found)).toEqual(["alpha", "zeta"]);
|
|
323
|
+
},
|
|
324
|
+
TIMEOUT,
|
|
325
|
+
);
|
|
326
|
+
|
|
327
|
+
test(
|
|
328
|
+
"a worktree whose folder was deleted without `git worktree remove` is skipped, not thrown on",
|
|
329
|
+
async () => {
|
|
330
|
+
const gone = await addWorktree("gone");
|
|
331
|
+
await fs.remove(gone);
|
|
332
|
+
|
|
333
|
+
const found = await findUnsetWorktrees(fx.main);
|
|
334
|
+
|
|
335
|
+
expect(names(found)).toEqual(["alpha", "zeta"]);
|
|
336
|
+
},
|
|
337
|
+
TIMEOUT,
|
|
338
|
+
);
|
|
339
|
+
|
|
340
|
+
test(
|
|
341
|
+
"a worktree on a branch without a manifest is skipped",
|
|
342
|
+
async () => {
|
|
343
|
+
await addWorktree("before-magus", { from: "bare" });
|
|
344
|
+
expect(
|
|
345
|
+
await fs.pathExists(
|
|
346
|
+
path.join(fx.base, "before-magus", ".claude", "profiles.json"),
|
|
347
|
+
),
|
|
348
|
+
).toBe(false);
|
|
349
|
+
|
|
350
|
+
const found = await findUnsetWorktrees(fx.main);
|
|
351
|
+
|
|
352
|
+
expect(names(found)).toEqual(["alpha", "zeta"]);
|
|
353
|
+
},
|
|
354
|
+
TIMEOUT,
|
|
355
|
+
);
|
|
356
|
+
|
|
357
|
+
test(
|
|
358
|
+
"a worktree that needs a choice is listed, but not as automatic",
|
|
359
|
+
async () => {
|
|
360
|
+
// No declared default on this branch: someone has to pick.
|
|
361
|
+
const choose = await addWorktree("choose", { manifest: NO_DEFAULT });
|
|
362
|
+
// A declared default, but a hand-written settings file would be replaced.
|
|
363
|
+
const kept = await addWorktree("kept");
|
|
364
|
+
await fs.outputJson(path.join(kept, ".claude", "settings.json"), {
|
|
365
|
+
enabledPlugins: { "dev@magus": true },
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
const found = await findUnsetWorktrees(fx.main);
|
|
369
|
+
const byName = new Map(found.map((w) => [w.name, w]));
|
|
370
|
+
|
|
371
|
+
expect(names(found)).toEqual(["alpha", "choose", "kept", "zeta"]);
|
|
372
|
+
expect(byName.get("choose")?.path).toBe(choose);
|
|
373
|
+
expect(byName.get("choose")?.declaredDefault).toBeNull();
|
|
374
|
+
expect(byName.get("choose")?.autoActivate).toBe(false);
|
|
375
|
+
expect(byName.get("kept")?.autoActivate).toBe(false);
|
|
376
|
+
expect(byName.get("alpha")?.autoActivate).toBe(true);
|
|
377
|
+
},
|
|
378
|
+
TIMEOUT,
|
|
379
|
+
);
|
|
380
|
+
|
|
381
|
+
test(
|
|
382
|
+
"writes nothing: every path and mtime under the repo, the worktrees and HOME is unchanged",
|
|
383
|
+
async () => {
|
|
384
|
+
// Include the awkward cases so their code paths run under the snapshot.
|
|
385
|
+
const gone = await addWorktree("gone");
|
|
386
|
+
await fs.remove(gone);
|
|
387
|
+
await addWorktree("before-magus", { from: "bare" });
|
|
388
|
+
await addWorktree("choose", { manifest: NO_DEFAULT });
|
|
389
|
+
|
|
390
|
+
// base holds the main tree (with .git), every worktree, and HOME.
|
|
391
|
+
const before = await snapshot(fx.base);
|
|
392
|
+
await findUnsetWorktrees(fx.main);
|
|
393
|
+
await findUnsetWorktrees(fx.alpha);
|
|
394
|
+
const after = await snapshot(fx.base);
|
|
395
|
+
|
|
396
|
+
expect(after).toEqual(before);
|
|
397
|
+
},
|
|
398
|
+
TIMEOUT,
|
|
399
|
+
);
|
|
400
|
+
|
|
401
|
+
test(
|
|
402
|
+
"not a git repository: an empty list",
|
|
403
|
+
async () => {
|
|
404
|
+
const plain = path.join(fx.base, "plain");
|
|
405
|
+
await writeManifest(plain, TEAM);
|
|
406
|
+
// Stop git's discovery at the temp dir, in case it sits inside a repo.
|
|
407
|
+
setEnv("GIT_CEILING_DIRECTORIES", path.dirname(fx.base));
|
|
408
|
+
|
|
409
|
+
expect(await findUnsetWorktrees(plain)).toEqual([]);
|
|
410
|
+
},
|
|
411
|
+
TIMEOUT,
|
|
412
|
+
);
|
|
413
|
+
|
|
414
|
+
test(
|
|
415
|
+
"a worktree that has been set up is no longer listed",
|
|
416
|
+
async () => {
|
|
417
|
+
const before = await findUnsetWorktrees(fx.main);
|
|
418
|
+
const alpha = before.filter((w) => w.name === "alpha");
|
|
419
|
+
expect(alpha).toHaveLength(1);
|
|
420
|
+
|
|
421
|
+
await setUpWorktrees(alpha);
|
|
422
|
+
|
|
423
|
+
expect(await stateOf(fx.alpha)).toBe("ok");
|
|
424
|
+
expect(names(await findUnsetWorktrees(fx.main))).toEqual(["zeta"]);
|
|
425
|
+
},
|
|
426
|
+
TIMEOUT,
|
|
427
|
+
);
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
// ─── setUpWorktrees ──────────────────────────────────────────────────────────
|
|
431
|
+
|
|
432
|
+
describe("setUpWorktrees", () => {
|
|
433
|
+
test(
|
|
434
|
+
"activates the declared profile in each automatic worktree, from that worktree's own manifest",
|
|
435
|
+
async () => {
|
|
436
|
+
// This branch declares a different default — the result must name it.
|
|
437
|
+
const beta = await addWorktree("beta", { manifest: SOLO_DEFAULT });
|
|
438
|
+
const mainBefore = await snapshotTree(fx.main);
|
|
439
|
+
|
|
440
|
+
const found = await findUnsetWorktrees(fx.main);
|
|
441
|
+
expect(names(found)).toEqual(["alpha", "beta", "zeta"]);
|
|
442
|
+
|
|
443
|
+
const results = await setUpWorktrees(found);
|
|
444
|
+
const sorted = [...results].sort((a, b) => a.name.localeCompare(b.name));
|
|
445
|
+
|
|
446
|
+
expect(sorted).toEqual([
|
|
447
|
+
{ path: fx.alpha, name: "alpha", ok: true, profileId: "team" },
|
|
448
|
+
{ path: beta, name: "beta", ok: true, profileId: "solo" },
|
|
449
|
+
{ path: fx.zeta, name: "zeta", ok: true, profileId: "team" },
|
|
450
|
+
]);
|
|
451
|
+
for (const tree of [fx.alpha, beta, fx.zeta]) {
|
|
452
|
+
expect(await stateOf(tree)).toBe("ok");
|
|
453
|
+
}
|
|
454
|
+
expect(await findUnsetWorktrees(fx.main)).toEqual([]);
|
|
455
|
+
|
|
456
|
+
// The caller's own tree was not in the list and is not touched: nothing
|
|
457
|
+
// is copied between worktrees.
|
|
458
|
+
expect(await snapshotTree(fx.main)).toEqual(mainBefore);
|
|
459
|
+
expect(await stateOf(fx.main)).not.toBe("ok");
|
|
460
|
+
},
|
|
461
|
+
TIMEOUT,
|
|
462
|
+
);
|
|
463
|
+
|
|
464
|
+
test(
|
|
465
|
+
"the main tree, found from a linked worktree, is set up too",
|
|
466
|
+
async () => {
|
|
467
|
+
const found = await findUnsetWorktrees(fx.zeta);
|
|
468
|
+
const mainEntry = found.filter((w) => w.name === "repo");
|
|
469
|
+
expect(mainEntry).toHaveLength(1);
|
|
470
|
+
|
|
471
|
+
const results = await setUpWorktrees(mainEntry);
|
|
472
|
+
|
|
473
|
+
expect(results).toEqual([
|
|
474
|
+
{ path: fx.main, name: "repo", ok: true, profileId: "team" },
|
|
475
|
+
]);
|
|
476
|
+
expect(await stateOf(fx.main)).toBe("ok");
|
|
477
|
+
expect(names(await findUnsetWorktrees(fx.alpha))).toEqual(["zeta"]);
|
|
478
|
+
},
|
|
479
|
+
TIMEOUT,
|
|
480
|
+
);
|
|
481
|
+
|
|
482
|
+
test(
|
|
483
|
+
"entries that are not automatic are neither returned nor touched",
|
|
484
|
+
async () => {
|
|
485
|
+
const choose = await addWorktree("choose", { manifest: NO_DEFAULT });
|
|
486
|
+
const kept = await addWorktree("kept");
|
|
487
|
+
await fs.outputJson(path.join(kept, ".claude", "settings.json"), {
|
|
488
|
+
enabledPlugins: { "dev@magus": true },
|
|
489
|
+
});
|
|
490
|
+
|
|
491
|
+
const found = await findUnsetWorktrees(fx.main);
|
|
492
|
+
expect(found.filter((w) => !w.autoActivate).map((w) => w.name)).toEqual([
|
|
493
|
+
"choose",
|
|
494
|
+
"kept",
|
|
495
|
+
]);
|
|
496
|
+
const chooseBefore = await snapshotTree(choose);
|
|
497
|
+
const keptBefore = await snapshotTree(kept);
|
|
498
|
+
|
|
499
|
+
const results = await setUpWorktrees(found);
|
|
500
|
+
|
|
501
|
+
expect(names(results).sort()).toEqual(["alpha", "zeta"]);
|
|
502
|
+
expect(results.every((r) => r.ok)).toBe(true);
|
|
503
|
+
expect(await snapshotTree(choose)).toEqual(chooseBefore);
|
|
504
|
+
expect(await snapshotTree(kept)).toEqual(keptBefore);
|
|
505
|
+
expect(names(await findUnsetWorktrees(fx.main))).toEqual([
|
|
506
|
+
"choose",
|
|
507
|
+
"kept",
|
|
508
|
+
]);
|
|
509
|
+
},
|
|
510
|
+
TIMEOUT,
|
|
511
|
+
);
|
|
512
|
+
|
|
513
|
+
test(
|
|
514
|
+
"a worktree deleted after detection fails with a reason; the others still succeed",
|
|
515
|
+
async () => {
|
|
516
|
+
const found = await findUnsetWorktrees(fx.main);
|
|
517
|
+
expect(names(found)).toEqual(["alpha", "zeta"]);
|
|
518
|
+
|
|
519
|
+
await fs.remove(fx.alpha);
|
|
520
|
+
const results = await setUpWorktrees(found);
|
|
521
|
+
|
|
522
|
+
const alpha = results.find((r) => r.name === "alpha");
|
|
523
|
+
const zeta = results.find((r) => r.name === "zeta");
|
|
524
|
+
expect(results).toHaveLength(2);
|
|
525
|
+
expect(alpha?.path).toBe(fx.alpha);
|
|
526
|
+
expect(alpha?.ok).toBe(false);
|
|
527
|
+
if (alpha && !alpha.ok) {
|
|
528
|
+
expect(typeof alpha.reason).toBe("string");
|
|
529
|
+
expect(alpha.reason.trim().length).toBeGreaterThan(0);
|
|
530
|
+
}
|
|
531
|
+
expect(zeta).toEqual({
|
|
532
|
+
path: fx.zeta,
|
|
533
|
+
name: "zeta",
|
|
534
|
+
ok: true,
|
|
535
|
+
profileId: "team",
|
|
536
|
+
});
|
|
537
|
+
expect(await stateOf(fx.zeta)).toBe("ok");
|
|
538
|
+
},
|
|
539
|
+
TIMEOUT,
|
|
540
|
+
);
|
|
541
|
+
|
|
542
|
+
test(
|
|
543
|
+
"a worktree that cannot be written fails with a reason; the others still succeed",
|
|
544
|
+
async () => {
|
|
545
|
+
const found = await findUnsetWorktrees(fx.main);
|
|
546
|
+
expect(names(found)).toEqual(["alpha", "zeta"]);
|
|
547
|
+
|
|
548
|
+
// Read-only after detection: the folder and its .claude directory.
|
|
549
|
+
const claudeDir = path.join(fx.zeta, ".claude");
|
|
550
|
+
fx.locked.push(claudeDir, fx.zeta);
|
|
551
|
+
await fs.chmod(claudeDir, 0o555);
|
|
552
|
+
await fs.chmod(fx.zeta, 0o555);
|
|
553
|
+
|
|
554
|
+
const results = await setUpWorktrees(found);
|
|
555
|
+
|
|
556
|
+
const alpha = results.find((r) => r.name === "alpha");
|
|
557
|
+
const zeta = results.find((r) => r.name === "zeta");
|
|
558
|
+
expect(results).toHaveLength(2);
|
|
559
|
+
expect(zeta?.path).toBe(fx.zeta);
|
|
560
|
+
expect(zeta?.ok).toBe(false);
|
|
561
|
+
if (zeta && !zeta.ok) {
|
|
562
|
+
expect(zeta.reason.trim().length).toBeGreaterThan(0);
|
|
563
|
+
}
|
|
564
|
+
expect(alpha).toEqual({
|
|
565
|
+
path: fx.alpha,
|
|
566
|
+
name: "alpha",
|
|
567
|
+
ok: true,
|
|
568
|
+
profileId: "team",
|
|
569
|
+
});
|
|
570
|
+
expect(await stateOf(fx.alpha)).toBe("ok");
|
|
571
|
+
expect(await stateOf(fx.zeta)).not.toBe("ok");
|
|
572
|
+
},
|
|
573
|
+
TIMEOUT,
|
|
574
|
+
);
|
|
575
|
+
|
|
576
|
+
test(
|
|
577
|
+
"nothing to set up: an empty result",
|
|
578
|
+
async () => {
|
|
579
|
+
expect(await setUpWorktrees([])).toEqual([]);
|
|
580
|
+
},
|
|
581
|
+
TIMEOUT,
|
|
582
|
+
);
|
|
583
|
+
});
|
package/src/cli/doctor.ts
CHANGED
|
@@ -8,7 +8,8 @@
|
|
|
8
8
|
* in `requires` (plus the profiles' cliTools), probed with each
|
|
9
9
|
* declaration's own check. `--fix` lists every install once, asks once
|
|
10
10
|
* (`--yes` skips the question) and installs through the deps executor.
|
|
11
|
-
* `--json` prints only this section
|
|
11
|
+
* `--json` prints only this section and section 6's list, as one
|
|
12
|
+
* document on stdout, and
|
|
12
13
|
* `--fix --json` repairs only this section: dependencies, nothing else.
|
|
13
14
|
* 2. Profile state — which profile is active (strictly), what the next magus
|
|
14
15
|
* command would record or regenerate (with `--fix`, it does it now and
|
|
@@ -18,6 +19,9 @@
|
|
|
18
19
|
* 3. Convention compliance — .gitignore + CLAUDE.md blocks (ported engine).
|
|
19
20
|
* 4. Model routing — config validity, hook registration, and drift.
|
|
20
21
|
* 5. Plugin loads — what Claude Code will load here vs what is installed.
|
|
22
|
+
* 6. Worktrees — the repository's other working trees with no profile set
|
|
23
|
+
* up, read only (`worktreesWithoutProfile` in `--json`); `magus update`
|
|
24
|
+
* sets them up.
|
|
21
25
|
*
|
|
22
26
|
* A file that exists and does not parse — the global settings, the project's
|
|
23
27
|
* `.claude/profiles.json` or its `.claude/settings.json`, Claude Code's
|
|
@@ -82,6 +86,7 @@ import {
|
|
|
82
86
|
switchHint,
|
|
83
87
|
} from "../services/profile-state.js";
|
|
84
88
|
import { resolveAllProfiles } from "../services/resolver.js";
|
|
89
|
+
import { findUnsetWorktrees } from "../services/worktree-profiles.js";
|
|
85
90
|
import type {
|
|
86
91
|
ClaudeSettings,
|
|
87
92
|
InstalledPluginEntry,
|
|
@@ -90,6 +95,7 @@ import type {
|
|
|
90
95
|
} from "../types/index.js";
|
|
91
96
|
import { resolveExecutable } from "../utils/command-utils.js";
|
|
92
97
|
import { depsSectionLines, fixDeps, renderApplyText } from "./deps-view.js";
|
|
98
|
+
import { worktreeLines } from "./update-view.js";
|
|
93
99
|
|
|
94
100
|
/**
|
|
95
101
|
* `--project <dir>`: the directory to check, or null for the current one. A
|
|
@@ -232,6 +238,14 @@ export async function runDoctorCommand(args: string[]): Promise<number> {
|
|
|
232
238
|
unfixable += await checkPluginLoads(projectPath);
|
|
233
239
|
|
|
234
240
|
if (problems === 0 && unfixable === 0) console.log("\n✓ No problems found.");
|
|
241
|
+
|
|
242
|
+
// 6. Other worktrees of this repository with no profile set up. After the
|
|
243
|
+
// verdict, which is about this folder: these are other projects. Reported
|
|
244
|
+
// only — doctor never writes them, `--fix` included — and outside the exit
|
|
245
|
+
// code; `magus update` sets them up.
|
|
246
|
+
const worktrees = await findUnsetWorktrees(projectPath);
|
|
247
|
+
for (const line of worktreeLines(worktrees, null)) console.log(line);
|
|
248
|
+
|
|
235
249
|
return doctorExitCode({ fixable: problems, unfixable, fix });
|
|
236
250
|
}
|
|
237
251
|
|
|
@@ -320,7 +334,15 @@ async function runDepsJson(
|
|
|
320
334
|
}
|
|
321
335
|
}
|
|
322
336
|
const report = depsReportJson(state, env, applied);
|
|
323
|
-
|
|
337
|
+
// The other worktrees of this repository with no profile set up. Read
|
|
338
|
+
// only, under `--fix` too, and no part of the exit code: they are other
|
|
339
|
+
// projects, and `magus update` is what sets them up.
|
|
340
|
+
const worktreesWithoutProfile = (await findUnsetWorktrees(projectPath)).map(
|
|
341
|
+
(w) => ({ name: w.name, path: w.path, state: w.state }),
|
|
342
|
+
);
|
|
343
|
+
process.stdout.write(
|
|
344
|
+
`${JSON.stringify({ ...report, worktreesWithoutProfile }, null, 2)}\n`,
|
|
345
|
+
);
|
|
324
346
|
const healthy =
|
|
325
347
|
report.deps.every((d) => d.status === "present") &&
|
|
326
348
|
report.invalid.length === 0 &&
|