magus-cli 7.9.1 → 7.10.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/package.json +4 -4
- package/src/__tests__/blind/plugin-deps/doctor.test.ts +1 -0
- package/src/__tests__/plugin-mcp-source-shapes.test.ts +250 -0
- package/src/__tests__/worktree-profiles.test.ts +583 -0
- package/src/cli/doctor.ts +24 -2
- package/src/cli/update-view.ts +73 -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/worktree-profiles.ts +181 -0
- package/src/ui/App.tsx +32 -1
- package/src/ui/adapters/startupReports.ts +22 -2
package/src/cli/update-view.ts
CHANGED
|
@@ -34,6 +34,10 @@ import {
|
|
|
34
34
|
type UpdatePlan,
|
|
35
35
|
isKeptDeprecated,
|
|
36
36
|
} from "../services/update-plan.js";
|
|
37
|
+
import type {
|
|
38
|
+
UnsetWorktree,
|
|
39
|
+
WorktreeSetupResult,
|
|
40
|
+
} from "../services/worktree-profiles.js";
|
|
37
41
|
import { type ReportBadgeKind, reportBadge } from "../ui/theme-mode.js";
|
|
38
42
|
import { brand } from "../ui/theme.js";
|
|
39
43
|
import { formatCommand } from "../utils/command-utils.js";
|
|
@@ -903,3 +907,72 @@ export function pendingHeadline(plan: UpdatePlan): string {
|
|
|
903
907
|
!plan.skills.some((s) => s.action === "install");
|
|
904
908
|
return onlyRemovals ? "Changes pending" : "Updates available";
|
|
905
909
|
}
|
|
910
|
+
|
|
911
|
+
// -- other worktrees -----------------------------------------------------------
|
|
912
|
+
//
|
|
913
|
+
// The other working trees of this repository that have no profile set up
|
|
914
|
+
// (`findUnsetWorktrees`). `magus update` sets up the ones the project already
|
|
915
|
+
// answered for, exactly as it does its own folder, and says so in one line.
|
|
916
|
+
// No section and no question: there is nothing to decide. Read-only runs
|
|
917
|
+
// (`magus doctor`, `--check`, `--dry-run`) get the same one line as a warning.
|
|
918
|
+
|
|
919
|
+
/** `Worktree a` or `Worktrees a, b, c + 2 more`, so the row stays one line. */
|
|
920
|
+
function worktreeLabel(names: readonly string[]): string {
|
|
921
|
+
const shown = names.slice(0, 3).join(", ");
|
|
922
|
+
const list = names.length > 3 ? `${shown} + ${names.length - 3} more` : shown;
|
|
923
|
+
return `${names.length === 1 ? "Worktree" : "Worktrees"} ${bold(list)}`;
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
/**
|
|
927
|
+
* The worktree rows: after a setup (`results`), one `✓` line for every
|
|
928
|
+
* worktree set up and a `✗` line for each failure; on a read-only run
|
|
929
|
+
* (`results` null), one `⚠` line for the ones `magus update` would set up.
|
|
930
|
+
* A worktree that needs a choice gets its own `⚠` line either way. Empty for
|
|
931
|
+
* an empty list: a repository with nothing to say prints nothing.
|
|
932
|
+
*/
|
|
933
|
+
export function worktreeLines(
|
|
934
|
+
worktrees: readonly UnsetWorktree[],
|
|
935
|
+
results: readonly WorktreeSetupResult[] | null,
|
|
936
|
+
): string[] {
|
|
937
|
+
if (worktrees.length === 0) return [];
|
|
938
|
+
const lines = [""];
|
|
939
|
+
if (results === null) {
|
|
940
|
+
const auto = worktrees.filter((w) => w.autoActivate).map((w) => w.name);
|
|
941
|
+
if (auto.length > 0) {
|
|
942
|
+
const one = auto.length === 1;
|
|
943
|
+
lines.push(
|
|
944
|
+
warn(
|
|
945
|
+
`${worktreeLabel(auto)} ${one ? "is" : "are"} missing this project's plugins. magus update sets ${one ? "it" : "them"} up.`,
|
|
946
|
+
),
|
|
947
|
+
);
|
|
948
|
+
}
|
|
949
|
+
} else {
|
|
950
|
+
const done = results.filter((r) => r.ok).map((r) => r.name);
|
|
951
|
+
if (done.length > 0) {
|
|
952
|
+
lines.push(
|
|
953
|
+
ok(
|
|
954
|
+
`${worktreeLabel(done)} now ${done.length === 1 ? "has" : "have"} this project's plugins.`,
|
|
955
|
+
),
|
|
956
|
+
);
|
|
957
|
+
}
|
|
958
|
+
for (const r of results) {
|
|
959
|
+
if (!r.ok) {
|
|
960
|
+
lines.push(
|
|
961
|
+
fail(
|
|
962
|
+
`Worktree ${bold(r.name)} was not set up: ${r.reason.replace(/\.+$/, "")}.`,
|
|
963
|
+
),
|
|
964
|
+
);
|
|
965
|
+
}
|
|
966
|
+
}
|
|
967
|
+
}
|
|
968
|
+
for (const w of worktrees) {
|
|
969
|
+
if (!w.autoActivate) {
|
|
970
|
+
lines.push(
|
|
971
|
+
warn(
|
|
972
|
+
`Worktree ${bold(w.name)} is missing this project's plugins. Run magus update there to choose a profile.`,
|
|
973
|
+
),
|
|
974
|
+
);
|
|
975
|
+
}
|
|
976
|
+
}
|
|
977
|
+
return lines;
|
|
978
|
+
}
|
package/src/cli/update.ts
CHANGED
|
@@ -84,6 +84,7 @@ import {
|
|
|
84
84
|
planSkillUpdates,
|
|
85
85
|
summarizePlan,
|
|
86
86
|
} from "../services/update-plan.js";
|
|
87
|
+
import type { ProfileManifest } from "../types/index.js";
|
|
87
88
|
import { brand } from "../ui/theme.js";
|
|
88
89
|
import { bold, dim, fg, width } from "./ansi.js";
|
|
89
90
|
import { ensureManifest, requireActiveProfile } from "./bootstrap.js";
|
|
@@ -108,6 +109,7 @@ import {
|
|
|
108
109
|
unknownToolsNotice,
|
|
109
110
|
warn as warnLine,
|
|
110
111
|
} from "./update-view.js";
|
|
112
|
+
import { setUpOtherWorktrees } from "./worktrees.js";
|
|
111
113
|
|
|
112
114
|
interface UpdateFlags {
|
|
113
115
|
/** Report drift and exit 1 if anything is behind. The CI gate. */
|
|
@@ -250,6 +252,25 @@ export async function runUpdateCommand(
|
|
|
250
252
|
// what it promised. A refused `--check` exits 1, because that is a gate.
|
|
251
253
|
if (!current) return flags.dryRun ? 0 : 1;
|
|
252
254
|
|
|
255
|
+
const code = await updateProfile(flags, projectPath, current, startedAt);
|
|
256
|
+
|
|
257
|
+
// This project is on a profile; now the other worktrees of the repository,
|
|
258
|
+
// set up the same way without asking. After the update, so their line is
|
|
259
|
+
// not lost above the plan. Read-only under `--check` and `--dry-run`, and
|
|
260
|
+
// never a change to the exit code: those are other projects.
|
|
261
|
+
await setUpOtherWorktrees(projectPath, {
|
|
262
|
+
mayWrite: !flags.check && !flags.dryRun,
|
|
263
|
+
});
|
|
264
|
+
return code;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/** The update itself, once the project's own profile gate has passed. */
|
|
268
|
+
async function updateProfile(
|
|
269
|
+
flags: UpdateFlags,
|
|
270
|
+
projectPath: string,
|
|
271
|
+
current: ProfileManifest,
|
|
272
|
+
startedAt: number,
|
|
273
|
+
): Promise<number> {
|
|
253
274
|
const profileIds = Object.keys(current.profiles);
|
|
254
275
|
if (flags.profile && !current.profiles[flags.profile]) {
|
|
255
276
|
console.error(
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The other worktrees of this repository at the end of `magus update`: the ones
|
|
3
|
+
* with no profile set up. Each one whose project already declares a profile,
|
|
4
|
+
* with nothing of the user's to replace, is set up without asking, exactly as
|
|
5
|
+
* `magus update` sets up its own folder. Then one line says so.
|
|
6
|
+
*
|
|
7
|
+
* Detection and apply are `services/worktree-profiles.ts`, shared with
|
|
8
|
+
* `magus doctor` and the TUI; the words are `update-view.ts`.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import {
|
|
12
|
+
findUnsetWorktrees,
|
|
13
|
+
setUpWorktrees,
|
|
14
|
+
} from "../services/worktree-profiles.js";
|
|
15
|
+
import { worktreeLines } from "./update-view.js";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Set up what can be set up when `mayWrite`, and print the rows. Under
|
|
19
|
+
* `--check` and `--dry-run` it only reports. Never changes the command's exit
|
|
20
|
+
* code: these are other projects, and a failure in one is reported on its row.
|
|
21
|
+
*/
|
|
22
|
+
export async function setUpOtherWorktrees(
|
|
23
|
+
projectPath: string,
|
|
24
|
+
opts: { mayWrite: boolean },
|
|
25
|
+
): Promise<void> {
|
|
26
|
+
const worktrees = await findUnsetWorktrees(projectPath);
|
|
27
|
+
if (worktrees.length === 0) return;
|
|
28
|
+
const results = opts.mayWrite
|
|
29
|
+
? await setUpWorktrees(worktrees.filter((w) => w.autoActivate))
|
|
30
|
+
: null;
|
|
31
|
+
for (const line of worktreeLines(worktrees, results)) console.log(line);
|
|
32
|
+
}
|
|
@@ -190,6 +190,47 @@ export async function inheritablePaths(projectPath: string): Promise<string[]> {
|
|
|
190
190
|
return paths;
|
|
191
191
|
}
|
|
192
192
|
|
|
193
|
+
/** One working tree of a repository, as `git worktree list` reports it. */
|
|
194
|
+
export interface WorkingTree {
|
|
195
|
+
/** Absolute path, as git recorded it. */
|
|
196
|
+
path: string;
|
|
197
|
+
/** git says its folder is gone (`prunable`); the record outlives it. */
|
|
198
|
+
prunable: boolean;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Every working tree of `projectPath`'s repository — the main one first, then
|
|
203
|
+
* each linked worktree — and the one `projectPath` itself sits in.
|
|
204
|
+
*
|
|
205
|
+
* Null when git cannot say: not a repository, git missing or not answering.
|
|
206
|
+
* A bare repository's own record has no working tree and is left out. Reads
|
|
207
|
+
* only; never caches, because a worktree added while magus runs is exactly
|
|
208
|
+
* what a caller of this wants to see.
|
|
209
|
+
*/
|
|
210
|
+
export async function listWorkingTrees(
|
|
211
|
+
projectPath: string,
|
|
212
|
+
): Promise<{ self: string; trees: WorkingTree[] } | null> {
|
|
213
|
+
const dir = path.resolve(projectPath);
|
|
214
|
+
const [top, list] = await Promise.all([
|
|
215
|
+
git(dir, ["rev-parse", "--show-toplevel"]),
|
|
216
|
+
git(dir, ["worktree", "list", "--porcelain"]),
|
|
217
|
+
]);
|
|
218
|
+
if (top.code !== 0 || !top.out || list.code !== 0) return null;
|
|
219
|
+
const trees: WorkingTree[] = [];
|
|
220
|
+
for (const record of list.out.split(/\n\s*\n/)) {
|
|
221
|
+
const lines = record.split("\n").map((l) => l.trim());
|
|
222
|
+
const line = lines.find((l) => l.startsWith("worktree "));
|
|
223
|
+
if (!line || lines.includes("bare")) continue;
|
|
224
|
+
trees.push({
|
|
225
|
+
path: path.resolve(line.slice("worktree ".length)),
|
|
226
|
+
prunable: lines.some(
|
|
227
|
+
(l) => l === "prunable" || l.startsWith("prunable "),
|
|
228
|
+
),
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
return { self: path.resolve(top.out), trees };
|
|
232
|
+
}
|
|
233
|
+
|
|
193
234
|
async function resolveInheritable(
|
|
194
235
|
dir: string,
|
|
195
236
|
): Promise<{ paths: string[]; answered: boolean }> {
|
|
@@ -217,7 +217,9 @@ export async function getPluginSourcePath(
|
|
|
217
217
|
const plugin = manifest.plugins.find(
|
|
218
218
|
(p: { name: string }) => p.name === pluginName,
|
|
219
219
|
);
|
|
220
|
-
|
|
220
|
+
// An object source (git-subdir, github, url) is fetched from its own
|
|
221
|
+
// repo, not read from the marketplace clone, so there is no local path.
|
|
222
|
+
return typeof plugin?.source === "string" ? plugin.source : undefined;
|
|
221
223
|
}
|
|
222
224
|
}
|
|
223
225
|
} catch {
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The other working trees of this repository that carry a magus manifest but
|
|
3
|
+
* have no profile in effect — and setting them up.
|
|
4
|
+
*
|
|
5
|
+
* A profiled project's generated files are git-ignored, so git never gives
|
|
6
|
+
* them to a new worktree. Claude Code treats the folder it starts in as the
|
|
7
|
+
* project and reads only that folder's settings, so a session started in such
|
|
8
|
+
* a worktree loads only the plugins turned on at USER scope. magus run in that
|
|
9
|
+
* worktree would fix it (`autoActivate`), but nothing tells the user to.
|
|
10
|
+
*
|
|
11
|
+
* So magus, run anywhere in the repository, looks at the other working trees
|
|
12
|
+
* too. Detection and apply are kept apart, and every surface — `magus update`,
|
|
13
|
+
* `magus doctor` and the TUI — uses these two functions; none of them decides
|
|
14
|
+
* anything of its own beyond how to show the result.
|
|
15
|
+
*
|
|
16
|
+
* Detection writes nothing, anywhere: it is `detectProfileStartupNeed` per
|
|
17
|
+
* working tree, which is detection only. Apply is the activation the normal
|
|
18
|
+
* gate runs for a fresh clone, against THAT worktree's own manifest — nothing
|
|
19
|
+
* is ever copied between worktrees.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { realpath, stat } from "node:fs/promises";
|
|
23
|
+
import path from "node:path";
|
|
24
|
+
import { listWorkingTrees } from "./git-worktree.js";
|
|
25
|
+
import { getManifestPath, readManifest } from "./manifest.js";
|
|
26
|
+
import {
|
|
27
|
+
type ProfileStartupState,
|
|
28
|
+
detectProfileStartupNeed,
|
|
29
|
+
} from "./profile-startup.js";
|
|
30
|
+
import { applyProfileChoice } from "./profile-state.js";
|
|
31
|
+
import { SettingsParseError } from "./settings-file.js";
|
|
32
|
+
|
|
33
|
+
export interface UnsetWorktree {
|
|
34
|
+
/** Absolute path of that working tree. */
|
|
35
|
+
path: string;
|
|
36
|
+
/** Its folder name, how the user knows it. */
|
|
37
|
+
name: string;
|
|
38
|
+
/** From `detectProfileStartupNeed(path)`; never "ok". */
|
|
39
|
+
state: ProfileStartupState;
|
|
40
|
+
declaredDefault: string | null;
|
|
41
|
+
/** The repo declares a profile and nothing would be replaced. */
|
|
42
|
+
autoActivate: boolean;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export type WorktreeSetupResult =
|
|
46
|
+
| { path: string; name: string; ok: true; profileId: string }
|
|
47
|
+
| { path: string; name: string; ok: false; reason: string };
|
|
48
|
+
|
|
49
|
+
/** The canonical spelling of a path, so `/tmp` and `/private/tmp` compare equal. */
|
|
50
|
+
async function canonical(p: string): Promise<string> {
|
|
51
|
+
try {
|
|
52
|
+
return await realpath(p);
|
|
53
|
+
} catch {
|
|
54
|
+
return path.resolve(p);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async function isDirectory(p: string): Promise<boolean> {
|
|
59
|
+
try {
|
|
60
|
+
return (await stat(p)).isDirectory();
|
|
61
|
+
} catch {
|
|
62
|
+
return false;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function isFile(p: string): Promise<boolean> {
|
|
67
|
+
try {
|
|
68
|
+
return (await stat(p)).isFile();
|
|
69
|
+
} catch {
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Read-only. The other working trees of `projectPath`'s repository that need
|
|
76
|
+
* setup, sorted by name.
|
|
77
|
+
*
|
|
78
|
+
* Left out: `projectPath`'s own working tree (the normal gate handles it), a
|
|
79
|
+
* working tree whose folder is gone, one with no manifest (not a magus
|
|
80
|
+
* project on that branch), and one already on a profile. Not a repository, or
|
|
81
|
+
* git failing, is `[]`.
|
|
82
|
+
*/
|
|
83
|
+
export async function findUnsetWorktrees(
|
|
84
|
+
projectPath: string,
|
|
85
|
+
): Promise<UnsetWorktree[]> {
|
|
86
|
+
let listing: Awaited<ReturnType<typeof listWorkingTrees>>;
|
|
87
|
+
try {
|
|
88
|
+
listing = await listWorkingTrees(projectPath);
|
|
89
|
+
} catch {
|
|
90
|
+
return [];
|
|
91
|
+
}
|
|
92
|
+
if (listing === null) return [];
|
|
93
|
+
const self = await canonical(listing.self);
|
|
94
|
+
|
|
95
|
+
const found = await Promise.all(
|
|
96
|
+
listing.trees.map(async (tree): Promise<UnsetWorktree | null> => {
|
|
97
|
+
if (tree.prunable || !(await isDirectory(tree.path))) return null;
|
|
98
|
+
const at = await canonical(tree.path);
|
|
99
|
+
if (at === self) return null;
|
|
100
|
+
if (!(await isFile(getManifestPath(at)))) return null;
|
|
101
|
+
try {
|
|
102
|
+
const need = await detectProfileStartupNeed(at);
|
|
103
|
+
if (need.state === "ok") return null;
|
|
104
|
+
return {
|
|
105
|
+
path: at,
|
|
106
|
+
name: path.basename(at),
|
|
107
|
+
state: need.state,
|
|
108
|
+
declaredDefault: need.declaredDefault,
|
|
109
|
+
autoActivate: need.autoActivate && need.declaredDefault !== null,
|
|
110
|
+
};
|
|
111
|
+
} catch {
|
|
112
|
+
// One working tree magus cannot read says nothing about the others.
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
}),
|
|
116
|
+
);
|
|
117
|
+
|
|
118
|
+
const seen = new Set<string>();
|
|
119
|
+
return found
|
|
120
|
+
.filter((w): w is UnsetWorktree => {
|
|
121
|
+
if (w === null || seen.has(w.path)) return false;
|
|
122
|
+
seen.add(w.path);
|
|
123
|
+
return true;
|
|
124
|
+
})
|
|
125
|
+
.sort((a, b) =>
|
|
126
|
+
a.name === b.name
|
|
127
|
+
? a.path.localeCompare(b.path)
|
|
128
|
+
: a.name.localeCompare(b.name),
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Why an activation failed, in one line that never ends on an absolute path. */
|
|
133
|
+
function failureReason(error: unknown, worktree: string): string {
|
|
134
|
+
if (error instanceof SettingsParseError) {
|
|
135
|
+
return `${path.relative(worktree, error.path)} ${error.problem}`;
|
|
136
|
+
}
|
|
137
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
138
|
+
return message.split("\n")[0] ?? message;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Activate the declared profile in each worktree whose `autoActivate` is true,
|
|
143
|
+
* one after another. Entries that need a choice are skipped and not returned;
|
|
144
|
+
* one failure does not stop the others.
|
|
145
|
+
*/
|
|
146
|
+
export async function setUpWorktrees(
|
|
147
|
+
worktrees: UnsetWorktree[],
|
|
148
|
+
): Promise<WorktreeSetupResult[]> {
|
|
149
|
+
const results: WorktreeSetupResult[] = [];
|
|
150
|
+
for (const wt of worktrees) {
|
|
151
|
+
if (!wt.autoActivate || wt.declaredDefault === null) continue;
|
|
152
|
+
try {
|
|
153
|
+
// That worktree's own manifest, read now: its branch may declare
|
|
154
|
+
// something other than this one's.
|
|
155
|
+
const manifest = await readManifest(wt.path);
|
|
156
|
+
const result = await applyProfileChoice(
|
|
157
|
+
{ kind: "activate", id: wt.declaredDefault },
|
|
158
|
+
manifest,
|
|
159
|
+
wt.path,
|
|
160
|
+
// Nothing to announce: `autoActivate` means nothing of the user's
|
|
161
|
+
// was there to move aside, and printing here would land inside
|
|
162
|
+
// whichever surface called this.
|
|
163
|
+
{ onDisplace: () => {} },
|
|
164
|
+
);
|
|
165
|
+
results.push({
|
|
166
|
+
path: wt.path,
|
|
167
|
+
name: wt.name,
|
|
168
|
+
ok: true,
|
|
169
|
+
profileId: result.profileId,
|
|
170
|
+
});
|
|
171
|
+
} catch (error) {
|
|
172
|
+
results.push({
|
|
173
|
+
path: wt.path,
|
|
174
|
+
name: wt.name,
|
|
175
|
+
ok: false,
|
|
176
|
+
reason: failureReason(error, wt.path),
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
return results;
|
|
181
|
+
}
|
package/src/ui/App.tsx
CHANGED
|
@@ -21,10 +21,15 @@ import {
|
|
|
21
21
|
checkForUpdates,
|
|
22
22
|
getCurrentVersion,
|
|
23
23
|
} from "../services/version-check.js";
|
|
24
|
+
import {
|
|
25
|
+
findUnsetWorktrees,
|
|
26
|
+
setUpWorktrees,
|
|
27
|
+
} from "../services/worktree-profiles.js";
|
|
24
28
|
import {
|
|
25
29
|
gitignoreStartupReport,
|
|
26
30
|
profileStartupReport,
|
|
27
31
|
staleRepairReport,
|
|
32
|
+
worktreeSetupReport,
|
|
28
33
|
} from "./adapters/startupReports.js";
|
|
29
34
|
import { ScreenErrorBoundary } from "./components/ScreenErrorBoundary.js";
|
|
30
35
|
import { TABS } from "./components/TabBar.js";
|
|
@@ -296,8 +301,15 @@ function AppContent({ onExit }: AppContentProps) {
|
|
|
296
301
|
null,
|
|
297
302
|
);
|
|
298
303
|
const staleLoadModal = useStaleLoadModal();
|
|
304
|
+
// This project's own profile question is settled: it needed nothing, or it
|
|
305
|
+
// was answered. Only then are the other worktrees set up — before it, they
|
|
306
|
+
// would be set up from a project that is not set up itself.
|
|
307
|
+
const [profileSettled, setProfileSettled] = useState(false);
|
|
299
308
|
// Stable, so `profileModal.show` is not rebuilt for it on every render.
|
|
300
|
-
const profileAnswered = React.useCallback(() =>
|
|
309
|
+
const profileAnswered = React.useCallback(() => {
|
|
310
|
+
setProfileNeed(null);
|
|
311
|
+
setProfileSettled(true);
|
|
312
|
+
}, []);
|
|
301
313
|
// Activating a profile changes which plugins are on, so the open screen must
|
|
302
314
|
// reload. The result modal that used to follow sent the user to the Profiles
|
|
303
315
|
// tab, which remounted it; without that modal the user stays where they are,
|
|
@@ -550,12 +562,31 @@ function AppContent({ onExit }: AppContentProps) {
|
|
|
550
562
|
);
|
|
551
563
|
queueReports(profileStartupReport(problems, activations ?? []));
|
|
552
564
|
if (!needsNothing(need)) setProfileNeed(need);
|
|
565
|
+
else setProfileSettled(true);
|
|
553
566
|
} catch {
|
|
554
567
|
// non-fatal: the Profiles tab still works by hand
|
|
555
568
|
}
|
|
556
569
|
})();
|
|
557
570
|
}, [queueReports]);
|
|
558
571
|
|
|
572
|
+
// The other worktrees of this repository with no profile set up, once this
|
|
573
|
+
// project's own question is settled. Each one whose project already declares
|
|
574
|
+
// a profile, with nothing of the user's to replace, is set up without
|
|
575
|
+
// asking — the same rule this project's own profile follows above. Success
|
|
576
|
+
// shows nothing; failures go to the one failure popup. A worktree that needs
|
|
577
|
+
// a choice is left to `magus update` run in it. Its own effect, for the
|
|
578
|
+
// same reason as the profile check above.
|
|
579
|
+
const worktreesLooked = useRef(false);
|
|
580
|
+
useEffect(() => {
|
|
581
|
+
if (!profileSettled || worktreesLooked.current) return;
|
|
582
|
+
worktreesLooked.current = true;
|
|
583
|
+
findUnsetWorktrees(process.cwd())
|
|
584
|
+
.then((found) => setUpWorktrees(found.filter((w) => w.autoActivate)))
|
|
585
|
+
.then((results) => queueReports(worktreeSetupReport(results)))
|
|
586
|
+
// non-fatal: `magus update` reports the same worktrees
|
|
587
|
+
.catch(() => {});
|
|
588
|
+
}, [profileSettled, queueReports]);
|
|
589
|
+
|
|
559
590
|
// Auto-refresh marketplaces on startup
|
|
560
591
|
useEffect(() => {
|
|
561
592
|
const noRefresh = process.argv.includes("--no-refresh");
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The failure popup's words for everything outside the Plugins screen's apply
|
|
3
3
|
* paths: the profile gate at startup, a profile activation, the startup
|
|
4
|
-
* repairs (stale plugin loads, marketplace refreshes, gitignore fixes)
|
|
5
|
-
* Git State screen. Each is a pure builder over what the service returned, on
|
|
4
|
+
* repairs (stale plugin loads, marketplace refreshes, gitignore fixes), the
|
|
5
|
+
* setup of other worktrees and the Git State screen. Each is a pure builder over what the service returned, on
|
|
6
6
|
* top of {@link failureReport}, so they read like every other failure.
|
|
7
7
|
*
|
|
8
8
|
* Each returns null when nothing failed: success says nothing, because the
|
|
@@ -13,6 +13,7 @@ import type { StaleLoadRepairResult } from "../../services/claude-settings.js";
|
|
|
13
13
|
import type { ActivationResult } from "../../services/profile-activate.js";
|
|
14
14
|
import { SettingsParseError } from "../../services/settings-file.js";
|
|
15
15
|
import { pluginName } from "../../services/update-engine.js";
|
|
16
|
+
import type { WorktreeSetupResult } from "../../services/worktree-profiles.js";
|
|
16
17
|
import { userFacingPath, withUserFacingPath } from "../../utils/user-path.js";
|
|
17
18
|
import type { FailureReport, FailureRow } from "../state/types.js";
|
|
18
19
|
import {
|
|
@@ -212,6 +213,25 @@ export function stepFailureReport(
|
|
|
212
213
|
};
|
|
213
214
|
}
|
|
214
215
|
|
|
216
|
+
/**
|
|
217
|
+
* Worktrees the startup setup could not put on their declared profile, each
|
|
218
|
+
* by folder name with its reason. Null when every one was set up.
|
|
219
|
+
*/
|
|
220
|
+
export function worktreeSetupReport(
|
|
221
|
+
results: readonly WorktreeSetupResult[],
|
|
222
|
+
): FailureReport | null {
|
|
223
|
+
const failed = results.filter(
|
|
224
|
+
(r): r is Extract<WorktreeSetupResult, { ok: false }> => !r.ok,
|
|
225
|
+
);
|
|
226
|
+
return failureReport(
|
|
227
|
+
`${counted(failed.length, "worktree")} ${failed.length === 1 ? "was" : "were"} not set up`,
|
|
228
|
+
failed.map((r) => ({
|
|
229
|
+
subject: r.name,
|
|
230
|
+
reason: exceptionReason(r.reason).reason,
|
|
231
|
+
})),
|
|
232
|
+
);
|
|
233
|
+
}
|
|
234
|
+
|
|
215
235
|
/**
|
|
216
236
|
* M9: generated files the startup gitignore fix could not untrack, each with
|
|
217
237
|
* git's own reason.
|