claudeup 4.35.0 → 4.35.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
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claudeup",
|
|
3
|
-
"version": "4.35.
|
|
3
|
+
"version": "4.35.1",
|
|
4
4
|
"description": "TUI tool for managing Claude Code plugins, MCPs, and configuration",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/main.tsx",
|
|
@@ -64,8 +64,8 @@
|
|
|
64
64
|
"typescript": "^5.6.3"
|
|
65
65
|
},
|
|
66
66
|
"optionalDependencies": {
|
|
67
|
-
"claudeup-darwin-arm64": "4.35.
|
|
68
|
-
"claudeup-darwin-x64": "4.35.
|
|
69
|
-
"claudeup-linux-x64": "4.35.
|
|
67
|
+
"claudeup-darwin-arm64": "4.35.1",
|
|
68
|
+
"claudeup-darwin-x64": "4.35.1",
|
|
69
|
+
"claudeup-linux-x64": "4.35.1"
|
|
70
70
|
}
|
|
71
71
|
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import {
|
|
2
|
+
afterAll,
|
|
3
|
+
beforeAll,
|
|
4
|
+
beforeEach,
|
|
5
|
+
describe,
|
|
6
|
+
expect,
|
|
7
|
+
test,
|
|
8
|
+
} from "bun:test";
|
|
9
|
+
import { execFileSync } from "node:child_process";
|
|
10
|
+
import os from "node:os";
|
|
11
|
+
import path from "node:path";
|
|
12
|
+
import fs from "fs-extra";
|
|
13
|
+
import {
|
|
14
|
+
clearWorktreeCache,
|
|
15
|
+
inheritablePaths,
|
|
16
|
+
} from "../services/git-worktree.js";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Exercised against real repositories rather than a mocked `git`, because the
|
|
20
|
+
* whole point of the function is agreeing with git about what a worktree is.
|
|
21
|
+
* A stub would have happily confirmed a wrong parse of `worktree list`.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
// Isolate from the operator's git config: a global `core.excludesFile`,
|
|
25
|
+
// `init.defaultBranch`, or commit signing would otherwise leak into these repos.
|
|
26
|
+
const GIT_CFG = [
|
|
27
|
+
"-c",
|
|
28
|
+
"user.name=test",
|
|
29
|
+
"-c",
|
|
30
|
+
"user.email=test@example.com",
|
|
31
|
+
"-c",
|
|
32
|
+
"commit.gpgsign=false",
|
|
33
|
+
"-c",
|
|
34
|
+
"init.defaultBranch=main",
|
|
35
|
+
];
|
|
36
|
+
|
|
37
|
+
const git = (cwd: string, ...args: string[]) =>
|
|
38
|
+
execFileSync("git", [...GIT_CFG, ...args], { cwd, encoding: "utf8" }).trim();
|
|
39
|
+
|
|
40
|
+
let base: string;
|
|
41
|
+
let mainTree: string;
|
|
42
|
+
let linked: string;
|
|
43
|
+
|
|
44
|
+
beforeAll(async () => {
|
|
45
|
+
// realpath: on macOS os.tmpdir() is a symlink, and git reports real paths.
|
|
46
|
+
base = await fs.realpath(
|
|
47
|
+
await fs.mkdtemp(path.join(os.tmpdir(), "claudeup-worktree-")),
|
|
48
|
+
);
|
|
49
|
+
mainTree = path.join(base, "repo");
|
|
50
|
+
linked = path.join(base, "linked");
|
|
51
|
+
|
|
52
|
+
await fs.ensureDir(mainTree);
|
|
53
|
+
git(mainTree, "init", "-q");
|
|
54
|
+
await fs.writeFile(path.join(mainTree, "README.md"), "hello\n");
|
|
55
|
+
git(mainTree, "add", "-A");
|
|
56
|
+
git(mainTree, "commit", "-qm", "initial");
|
|
57
|
+
git(mainTree, "worktree", "add", "-q", "-b", "feature", linked);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
afterAll(async () => {
|
|
61
|
+
if (base) await fs.remove(base);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
beforeEach(() => {
|
|
65
|
+
clearWorktreeCache();
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
describe("inheritablePaths", () => {
|
|
69
|
+
test("a linked worktree inherits from the main working tree", async () => {
|
|
70
|
+
expect(await inheritablePaths(linked)).toEqual([mainTree]);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("the main working tree inherits nothing — it holds its own rows", async () => {
|
|
74
|
+
expect(await inheritablePaths(mainTree)).toEqual([]);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("a subdirectory of a checkout inherits from the repository root", async () => {
|
|
78
|
+
const sub = path.join(mainTree, "packages", "app");
|
|
79
|
+
await fs.ensureDir(sub);
|
|
80
|
+
expect(await inheritablePaths(sub)).toEqual([mainTree]);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("a subdirectory of a linked worktree inherits both roots, nearest first", async () => {
|
|
84
|
+
const sub = path.join(linked, "packages", "app");
|
|
85
|
+
await fs.ensureDir(sub);
|
|
86
|
+
expect(await inheritablePaths(sub)).toEqual([linked, mainTree]);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test("a directory outside any repository inherits nothing", async () => {
|
|
90
|
+
const loose = path.join(base, "not-a-repo");
|
|
91
|
+
await fs.ensureDir(loose);
|
|
92
|
+
// Guard: a stray repo above the temp dir would silently invalidate this.
|
|
93
|
+
const out = await inheritablePaths(loose);
|
|
94
|
+
expect(out.every((p) => !p.startsWith(base))).toBe(true);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test("a missing directory yields nothing rather than throwing", async () => {
|
|
98
|
+
expect(await inheritablePaths(path.join(base, "gone"))).toEqual([]);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test("results are cached per path and cleared on demand", async () => {
|
|
102
|
+
const first = await inheritablePaths(linked);
|
|
103
|
+
expect(await inheritablePaths(linked)).toBe(first); // same array identity
|
|
104
|
+
clearWorktreeCache();
|
|
105
|
+
expect(await inheritablePaths(linked)).not.toBe(first);
|
|
106
|
+
expect(await inheritablePaths(linked)).toEqual([mainTree]);
|
|
107
|
+
});
|
|
108
|
+
});
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { pickRegistryEntry } from "../services/claude-settings.js";
|
|
3
|
+
import type { InstalledPluginEntry } from "../types/index.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Regression cover for "a fresh git worktree reports every plugin as not installed".
|
|
7
|
+
*
|
|
8
|
+
* `installed_plugins.json` keys one entry per `projectPath`, and a new worktree is
|
|
9
|
+
* a project path nobody has ever installed into — measured on a real machine:
|
|
10
|
+
* 750 project-scope rows, zero of them for the worktree, and zero user-scope rows
|
|
11
|
+
* for any magus plugin. So `pickRegistryEntry` found nothing, `overlayRegistryVersions`
|
|
12
|
+
* deleted the version as an unbacked claim, and `isInstalledInScope` reported the
|
|
13
|
+
* plugin missing.
|
|
14
|
+
*
|
|
15
|
+
* Claude Code disagreed: it loaded all of them. Its loader is not gated on the
|
|
16
|
+
* current project — a worktree with no rows resolves `installPath` from another
|
|
17
|
+
* project's row and loads from cache (measured, `ai-docs/plugin-system-state-model.md`).
|
|
18
|
+
* So claudeup was offering to install plugins that were already working.
|
|
19
|
+
*
|
|
20
|
+
* A linked worktree is the same project as its main working tree, so the main
|
|
21
|
+
* tree's row is the honest answer for it.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
const entry = (
|
|
25
|
+
scope: InstalledPluginEntry["scope"],
|
|
26
|
+
version: string,
|
|
27
|
+
projectPath?: string,
|
|
28
|
+
): InstalledPluginEntry => ({
|
|
29
|
+
scope,
|
|
30
|
+
version,
|
|
31
|
+
projectPath,
|
|
32
|
+
installPath: `/cache/${version}`,
|
|
33
|
+
installedAt: "2026-01-01T00:00:00.000Z",
|
|
34
|
+
lastUpdated: "2026-01-01T00:00:00.000Z",
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
const MAIN = "/repo";
|
|
38
|
+
const TREE = "/repo/.claude/worktrees/feature";
|
|
39
|
+
|
|
40
|
+
describe("pickRegistryEntry — worktree fallback", () => {
|
|
41
|
+
test("a worktree with no rows of its own resolves to the main working tree's row", () => {
|
|
42
|
+
const entries = [entry("project", "3.3.0", MAIN)];
|
|
43
|
+
expect(pickRegistryEntry(entries, "project", TREE, [MAIN])?.version).toBe(
|
|
44
|
+
"3.3.0",
|
|
45
|
+
);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("the worktree's own row still wins over the main tree's row", () => {
|
|
49
|
+
const entries = [
|
|
50
|
+
entry("project", "3.3.0", MAIN),
|
|
51
|
+
entry("project", "4.0.0", TREE),
|
|
52
|
+
];
|
|
53
|
+
expect(pickRegistryEntry(entries, "project", TREE, [MAIN])?.version).toBe(
|
|
54
|
+
"4.0.0",
|
|
55
|
+
);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("a local-scope row on the main tree satisfies a worktree project lookup", () => {
|
|
59
|
+
const entries = [entry("local", "5.0.0", MAIN)];
|
|
60
|
+
expect(pickRegistryEntry(entries, "project", TREE, [MAIN])?.version).toBe(
|
|
61
|
+
"5.0.0",
|
|
62
|
+
);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("the main-tree row outranks a user-scope entry", () => {
|
|
66
|
+
// Same project beats "installed globally somewhere". The user entry is the
|
|
67
|
+
// looser fallback and stays available when the main tree has no row.
|
|
68
|
+
const entries = [entry("user", "1.0.0"), entry("project", "3.3.0", MAIN)];
|
|
69
|
+
expect(pickRegistryEntry(entries, "project", TREE, [MAIN])?.version).toBe(
|
|
70
|
+
"3.3.0",
|
|
71
|
+
);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("the user-scope entry still applies when the main tree has no row either", () => {
|
|
75
|
+
const entries = [
|
|
76
|
+
entry("user", "2.1.2"),
|
|
77
|
+
entry("project", "9.9.9", "/elsewhere"),
|
|
78
|
+
];
|
|
79
|
+
expect(pickRegistryEntry(entries, "project", TREE, [MAIN])?.version).toBe(
|
|
80
|
+
"2.1.2",
|
|
81
|
+
);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("an unrelated project's row is never inherited", () => {
|
|
85
|
+
// The loader may well resolve one of these, but claudeup must not claim a
|
|
86
|
+
// version it cannot attribute to this project.
|
|
87
|
+
const entries = [entry("project", "9.9.9", "/some/other/project")];
|
|
88
|
+
expect(pickRegistryEntry(entries, "project", TREE, [MAIN])).toBeUndefined();
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("fallback paths are compared resolved, not as raw strings", () => {
|
|
92
|
+
const entries = [entry("project", "4.2.0", "/repo/./sub/..")];
|
|
93
|
+
expect(pickRegistryEntry(entries, "project", TREE, [MAIN])?.version).toBe(
|
|
94
|
+
"4.2.0",
|
|
95
|
+
);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test("fallback paths are ignored for a user-scope lookup", () => {
|
|
99
|
+
const entries = [entry("project", "3.3.0", MAIN)];
|
|
100
|
+
expect(pickRegistryEntry(entries, "user", TREE, [MAIN])).toBeUndefined();
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("omitting fallback paths preserves the previous behaviour", () => {
|
|
104
|
+
const entries = [entry("project", "3.3.0", MAIN)];
|
|
105
|
+
expect(pickRegistryEntry(entries, "project", TREE)).toBeUndefined();
|
|
106
|
+
});
|
|
107
|
+
});
|
|
@@ -12,6 +12,7 @@ import type {
|
|
|
12
12
|
InstalledPluginEntry,
|
|
13
13
|
} from "../types/index.js";
|
|
14
14
|
import { parsePluginId } from "../utils/string-utils.js";
|
|
15
|
+
import { inheritablePaths } from "./git-worktree.js";
|
|
15
16
|
|
|
16
17
|
const CLAUDE_DIR = ".claude";
|
|
17
18
|
const SETTINGS_FILE = "settings.json";
|
|
@@ -261,27 +262,44 @@ export async function getLocalEnabledPlugins(
|
|
|
261
262
|
* The registry is what Claude Code actually loaded, so it wins on conflict.
|
|
262
263
|
*
|
|
263
264
|
* Scope resolution mirrors Claude Code: for a project scope, prefer an entry
|
|
264
|
-
* pinned to that exact projectPath (project or local), then
|
|
265
|
-
*
|
|
265
|
+
* pinned to that exact projectPath (project or local), then any `fallbackPaths`
|
|
266
|
+
* that are the same repository (see git-worktree.ts), then the user-scope entry,
|
|
267
|
+
* which is what a project without its own install resolves to.
|
|
266
268
|
*/
|
|
267
269
|
export function pickRegistryEntry(
|
|
268
270
|
entries: InstalledPluginEntry[] | undefined,
|
|
269
271
|
scope: "user" | "project" | "local",
|
|
270
272
|
projectPath?: string,
|
|
273
|
+
/**
|
|
274
|
+
* Same-repository directories whose rows may be inherited, nearest first —
|
|
275
|
+
* a linked worktree's main working tree, or the root of this checkout.
|
|
276
|
+
*
|
|
277
|
+
* A fresh worktree has no rows of its own, so without this every plugin read
|
|
278
|
+
* as not installed while Claude Code was loading all of them quite happily.
|
|
279
|
+
* These rank above the user-scope entry: "installed in this repository" is a
|
|
280
|
+
* closer answer than "installed globally somewhere".
|
|
281
|
+
*/
|
|
282
|
+
fallbackPaths: string[] = [],
|
|
271
283
|
): InstalledPluginEntry | undefined {
|
|
272
284
|
if (!Array.isArray(entries) || entries.length === 0) return undefined;
|
|
273
285
|
|
|
274
286
|
if (scope === "user") return entries.find((e) => e.scope === "user");
|
|
275
287
|
|
|
276
|
-
const
|
|
277
|
-
|
|
278
|
-
const pinned = entries.find(
|
|
288
|
+
const pinnedTo = (target: string) =>
|
|
289
|
+
entries.find(
|
|
279
290
|
(e) =>
|
|
280
291
|
(e.scope === scope || e.scope === "local") &&
|
|
281
292
|
e.projectPath !== undefined &&
|
|
282
293
|
path.resolve(e.projectPath) === target,
|
|
283
294
|
);
|
|
284
|
-
|
|
295
|
+
|
|
296
|
+
if (projectPath) {
|
|
297
|
+
const own = pinnedTo(path.resolve(projectPath));
|
|
298
|
+
if (own) return own;
|
|
299
|
+
}
|
|
300
|
+
for (const fallback of fallbackPaths) {
|
|
301
|
+
const inherited = pinnedTo(path.resolve(fallback));
|
|
302
|
+
if (inherited) return inherited;
|
|
285
303
|
}
|
|
286
304
|
// A project with no install of its own resolves to the user-scope entry.
|
|
287
305
|
return entries.find((e) => e.scope === "user");
|
|
@@ -299,9 +317,21 @@ async function overlayRegistryVersions(
|
|
|
299
317
|
return base; // registry unreadable — settings-only is still better than nothing
|
|
300
318
|
}
|
|
301
319
|
|
|
320
|
+
// `readSettings(undefined)` already resolves to the current directory, so the
|
|
321
|
+
// registry lookup has to as well. While it did not, the base map described one
|
|
322
|
+
// project while the overlay refused to match any project's rows at all, and every
|
|
323
|
+
// version was deleted as unbacked — which is why the prerunner and the global
|
|
324
|
+
// plugin view, both of which pass no path, saw project-scope plugins as
|
|
325
|
+
// uninstalled everywhere, not only in a worktree.
|
|
326
|
+
const target = scope === "user" ? undefined : (projectPath ?? process.cwd());
|
|
327
|
+
|
|
328
|
+
// Resolved once, not per plugin: this shells out to git, and the loop runs
|
|
329
|
+
// across every key in the registry (76 on a well-used machine) for each scope.
|
|
330
|
+
const fallbackPaths = target ? await inheritablePaths(target) : [];
|
|
331
|
+
|
|
302
332
|
const merged = { ...base };
|
|
303
333
|
for (const [pluginId, entries] of Object.entries(registry.plugins ?? {})) {
|
|
304
|
-
const pick = pickRegistryEntry(entries, scope,
|
|
334
|
+
const pick = pickRegistryEntry(entries, scope, target, fallbackPaths);
|
|
305
335
|
if (pick?.version) {
|
|
306
336
|
merged[pluginId] = pick.version;
|
|
307
337
|
continue;
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* git-worktree.ts — which other directories count as "the same project" as this one.
|
|
3
|
+
*
|
|
4
|
+
* `installed_plugins.json` keys one entry per `projectPath`, so a directory that
|
|
5
|
+
* nobody has run `claude plugin install` in has no rows at all. A freshly created
|
|
6
|
+
* git worktree is exactly that: measured on a real machine, 750 project-scope rows
|
|
7
|
+
* across 76 plugin keys and not one of them for the new worktree.
|
|
8
|
+
*
|
|
9
|
+
* Claude Code does not care. Its loader is not gated on the current project — a
|
|
10
|
+
* worktree with zero rows resolves `installPath` from another project's row and
|
|
11
|
+
* loads from cache (measured, `ai-docs/plugin-system-state-model.md`). claudeup
|
|
12
|
+
* mirrored the registry faithfully instead, so it reported every plugin as missing
|
|
13
|
+
* and offered to install things that were already working.
|
|
14
|
+
*
|
|
15
|
+
* The fix is to ask a narrower question than "does the loader find something
|
|
16
|
+
* somewhere": inherit rows only from directories that are the *same repository*.
|
|
17
|
+
* That covers both ways a project path can miss its own rows —
|
|
18
|
+
*
|
|
19
|
+
* 1. a linked worktree, whose main working tree holds the rows
|
|
20
|
+
* 2. a subdirectory of a checkout, where the rows sit at the repo root
|
|
21
|
+
*
|
|
22
|
+
* — under one rule, and never inherits from an unrelated project, which would be
|
|
23
|
+
* claiming a version we cannot attribute.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { spawn } from "node:child_process";
|
|
27
|
+
import path from "node:path";
|
|
28
|
+
|
|
29
|
+
function git(
|
|
30
|
+
cwd: string,
|
|
31
|
+
args: string[],
|
|
32
|
+
): Promise<{ code: number; out: string }> {
|
|
33
|
+
return new Promise((resolve) => {
|
|
34
|
+
const child = spawn("git", args, {
|
|
35
|
+
cwd,
|
|
36
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
37
|
+
});
|
|
38
|
+
let out = "";
|
|
39
|
+
child.stdout.on("data", (d) => {
|
|
40
|
+
out += String(d);
|
|
41
|
+
});
|
|
42
|
+
// Never let a wedged git block the UI.
|
|
43
|
+
const timer = setTimeout(() => {
|
|
44
|
+
child.kill();
|
|
45
|
+
resolve({ code: -1, out: "" });
|
|
46
|
+
}, 5000);
|
|
47
|
+
timer.unref?.();
|
|
48
|
+
const settle = (code: number, text: string) => {
|
|
49
|
+
clearTimeout(timer);
|
|
50
|
+
resolve({ code, out: text });
|
|
51
|
+
};
|
|
52
|
+
child.on("error", () => settle(-1, ""));
|
|
53
|
+
child.on("close", (code) => settle(code ?? -1, out.trim()));
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Session cache. Keyed by resolved project path — the repository layout cannot
|
|
59
|
+
* change under a running claudeup in any way worth tracking, and the resolution
|
|
60
|
+
* runs once per plugin per scope otherwise (24+ plugins × 3 scopes of `git`).
|
|
61
|
+
*/
|
|
62
|
+
const cache = new Map<string, string[]>();
|
|
63
|
+
|
|
64
|
+
export function clearWorktreeCache(): void {
|
|
65
|
+
cache.clear();
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Directories whose registry rows this project may inherit, nearest first.
|
|
70
|
+
*
|
|
71
|
+
* Returns `[]` — never a guess — when the question cannot be answered honestly:
|
|
72
|
+
* git missing, not a repository, a bare repo with no working tree, or the path
|
|
73
|
+
* already being the repository root. Inheriting nothing restores the previous
|
|
74
|
+
* behaviour for that path, which is the safe direction: a false negative shows
|
|
75
|
+
* an "install" the user does not need, a false positive hides a real one.
|
|
76
|
+
*/
|
|
77
|
+
export async function inheritablePaths(projectPath: string): Promise<string[]> {
|
|
78
|
+
const key = path.resolve(projectPath);
|
|
79
|
+
const cached = cache.get(key);
|
|
80
|
+
if (cached) return cached;
|
|
81
|
+
const { paths, answered } = await resolveInheritable(key);
|
|
82
|
+
// Only cache an answer git actually gave. A timeout or a transient spawn
|
|
83
|
+
// failure would otherwise pin an empty result for the rest of the session,
|
|
84
|
+
// turning one slow moment into a persistent wrong "not installed".
|
|
85
|
+
if (answered) cache.set(key, paths);
|
|
86
|
+
return paths;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async function resolveInheritable(
|
|
90
|
+
dir: string,
|
|
91
|
+
): Promise<{ paths: string[]; answered: boolean }> {
|
|
92
|
+
const candidates: string[] = [];
|
|
93
|
+
|
|
94
|
+
// Independent queries, so run them together: sequentially, two wedged git calls
|
|
95
|
+
// would stall the first paint for the full 10s rather than 5.
|
|
96
|
+
const [top, list] = await Promise.all([
|
|
97
|
+
// The root of whichever working tree we are standing in. Covers a subdirectory
|
|
98
|
+
// of a checkout, and is the nearer answer when both apply.
|
|
99
|
+
git(dir, ["rev-parse", "--show-toplevel"]),
|
|
100
|
+
// The main working tree, which `git worktree list` always lists first. This is
|
|
101
|
+
// the row-bearing directory for a linked worktree.
|
|
102
|
+
git(dir, ["worktree", "list", "--porcelain"]),
|
|
103
|
+
]);
|
|
104
|
+
|
|
105
|
+
if (top.code === 0 && top.out) candidates.push(path.resolve(top.out));
|
|
106
|
+
|
|
107
|
+
if (list.code === 0 && list.out) {
|
|
108
|
+
const firstRecord = list.out.split("\n\n")[0].split("\n");
|
|
109
|
+
// A bare repo is listed with a `bare` line and has no working tree to inherit.
|
|
110
|
+
const isBare = firstRecord.some((l) => l.trim() === "bare");
|
|
111
|
+
const line = firstRecord.find((l) => l.startsWith("worktree "));
|
|
112
|
+
if (!isBare && line) {
|
|
113
|
+
candidates.push(path.resolve(line.slice("worktree ".length).trim()));
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// "Not a repository" is a real answer worth caching; "git never replied" is not.
|
|
118
|
+
const answered = top.code !== -1 || list.code !== -1;
|
|
119
|
+
|
|
120
|
+
// Drop this project itself and any duplicate, preserving nearest-first order.
|
|
121
|
+
const seen = new Set<string>([dir]);
|
|
122
|
+
const inheritable: string[] = [];
|
|
123
|
+
for (const candidate of candidates) {
|
|
124
|
+
if (seen.has(candidate)) continue;
|
|
125
|
+
seen.add(candidate);
|
|
126
|
+
inheritable.push(candidate);
|
|
127
|
+
}
|
|
128
|
+
return { paths: inheritable, answered };
|
|
129
|
+
}
|
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
removeFromInstalledPluginsRegistry,
|
|
19
19
|
} from "./claude-settings.js";
|
|
20
20
|
import { hasContentDrift } from "./content-drift.js";
|
|
21
|
+
import { inheritablePaths } from "./git-worktree.js";
|
|
21
22
|
import { defaultMarketplaces } from "../data/marketplaces.js";
|
|
22
23
|
import type {
|
|
23
24
|
InstalledPluginsRegistry,
|
|
@@ -616,6 +617,13 @@ async function annotateContentDrift(
|
|
|
616
617
|
return;
|
|
617
618
|
}
|
|
618
619
|
|
|
620
|
+
// Must match how the version itself was resolved. Without this a worktree
|
|
621
|
+
// inherits an installed version but never its `gitCommitSha`, so drift
|
|
622
|
+
// detection silently switches off for exactly the plugins that just started
|
|
623
|
+
// reporting as installed.
|
|
624
|
+
const target = scope === "user" ? undefined : (projectPath ?? process.cwd());
|
|
625
|
+
const fallbackPaths = target ? await inheritablePaths(target) : [];
|
|
626
|
+
|
|
619
627
|
await Promise.all(
|
|
620
628
|
plugins.map(async (plugin) => {
|
|
621
629
|
if (plugin.hasUpdate || plugin.isOrphaned) return;
|
|
@@ -625,7 +633,8 @@ async function annotateContentDrift(
|
|
|
625
633
|
const entry = pickRegistryEntry(
|
|
626
634
|
registry.plugins[plugin.id],
|
|
627
635
|
scope,
|
|
628
|
-
|
|
636
|
+
target,
|
|
637
|
+
fallbackPaths,
|
|
629
638
|
);
|
|
630
639
|
if (!entry?.gitCommitSha) return;
|
|
631
640
|
|