From dca12242b56ef0e0ef839c0468325a61e8eeb2a2 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Sat, 2 May 2026 17:44:52 -0400 Subject: [PATCH] fix(install): skip Gemini local commands/gsd when global GSD present (#3037) (#3041) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(install): skip Gemini local commands/gsd when global GSD present (#3037) Reporter showed that running `npx get-shit-done-cc --gemini --global` followed by `--gemini --local` in a project creates the same 65 GSD command files in both Gemini scopes: - ~/.gemini/commands/gsd/ (user scope) - /.gemini/commands/gsd/ (workspace scope) Gemini conflict-detects by command name across scopes and renames every overlapping /gsd:* command to /workspace.gsd:* and /user.gsd:*, breaking the documented /gsd:* namespace. Fix: in bin/install.js, when handling --gemini --local, detect whether ~/.gemini/commands/gsd/ already exists with managed-shape content. If so, skip the local copy and print a clear three-line warning explaining the conflict avoidance. The user-scope install already provides the same /gsd:* commands in this project; the local copy adds zero value. Sibling fixes (test isolation): - tests/install-minimal-all-runtimes.test.cjs: pass HOME/USERPROFILE through the spawned installer's env so the developer's real ~/.gemini/commands/gsd/ doesn't trigger the new skip path during test runs that want to assert the local-install populates commands/gsd/. - tests/gemini-namespacing.test.cjs: the "Gemini Install (Behavioral)" describe block now creates an isolated tmpHome and points process.env.HOME at it before calling install(false, 'gemini'), with proper restore in afterEach. Test: - tests/bug-3037-gemini-duplicate-commands.test.cjs — 4 structural tests: 1. global install populates HOME/.gemini/commands/gsd 2. local install AFTER global skips the local copy 3. local install with NO existing global still populates locally (no-regression) 4. local install when HOME has .gemini/ but no GSD-managed commands/gsd/ still populates locally (non-GSD-Gemini-user no-regression) 6909/6909 full suite pass. Lints clean. Closes #3037 Co-Authored-By: Claude Opus 4.7 * fix: address CR feedback on PR #3041 — narrower detection + USERPROFILE restore CR findings: 1. **bin/install.js (Major)** — userScopeHasGsd used `fs.readdirSync(homeGeminiGsd).length > 0` which would skip the local install for any non-empty directory, including a user who hand-dropped a single override at ~/.gemini/commands/gsd/ .toml without ever running --gemini --global. Narrowed the detection to require at least 3 canonical GSD command files (help.toml, progress.toml, new-project.toml) — a marker that ships in every GSD Gemini install (minimal mode included) and is structurally impossible to produce by accident. 2. **tests/bug-3037-...:59 (Minor)** — beforeEach overwrites process.env.USERPROFILE but afterEach only restores HOME, leaking the temp home into later tests on Windows or any code path that reads USERPROFILE. Added save/restore symmetric with HOME. Plus added a 5th regression test covering the narrowed detection: "local install when HOME has hand-dropped overrides UNDER commands/gsd/ (but no full GSD) still populates locally" — directly exercises the edge case CR identified. 5/5 targeted tests pass. 6910/6910 full suite pass. Co-Authored-By: Claude Opus 4.7 --------- Co-authored-by: Claude Opus 4.7 --- .changeset/gemini-skip-local-when-global.md | 5 + bin/install.js | 56 +++++- ...ug-3037-gemini-duplicate-commands.test.cjs | 185 ++++++++++++++++++ tests/gemini-namespacing.test.cjs | 17 ++ tests/install-minimal-all-runtimes.test.cjs | 7 + 5 files changed, 262 insertions(+), 8 deletions(-) create mode 100644 .changeset/gemini-skip-local-when-global.md create mode 100644 tests/bug-3037-gemini-duplicate-commands.test.cjs diff --git a/.changeset/gemini-skip-local-when-global.md b/.changeset/gemini-skip-local-when-global.md new file mode 100644 index 000000000..2a98fcadc --- /dev/null +++ b/.changeset/gemini-skip-local-when-global.md @@ -0,0 +1,5 @@ +--- +type: Fixed +pr: 3037 +--- +**Gemini local install no longer duplicates `/gsd:*` commands across user and workspace scopes** — when GSD is already installed at the user scope (`~/.gemini/commands/gsd/`) and you run `npx get-shit-done-cc --gemini --local` in a project, the installer now skips writing `commands/gsd/` to `/.gemini/` and prints a one-line warning explaining why. Previously, both scopes received the same 65 command files, and Gemini's conflict detector renamed every `/gsd:*` command to `/workspace.gsd:*` and `/user.gsd:*`, breaking the documented namespace. Closes #3037. diff --git a/bin/install.js b/bin/install.js index 738a2f7b7..42c19c450 100755 --- a/bin/install.js +++ b/bin/install.js @@ -7556,15 +7556,55 @@ function install(isGlobal, runtime = 'claude') { // No skills/commands directory needed. Engine is installed via copyWithPathReplacement. console.log(` ${green}✓${reset} Cline: commands will be available via .clinerules`); } else if (isGemini) { - const commandsDir = path.join(targetDir, 'commands'); - fs.mkdirSync(commandsDir, { recursive: true }); - const gsdSrc = stageSkillsForMode(path.join(src, 'commands', 'gsd'), installMode); - const gsdDest = path.join(commandsDir, 'gsd'); - copyWithPathReplacement(gsdSrc, gsdDest, pathPrefix, runtime, true, isGlobal); - if (verifyInstalled(gsdDest, 'commands/gsd')) { - console.log(` ${green}✓${reset} Installed commands/gsd`); + // #3037: when running --local --gemini and a GSD-managed user-scope + // command directory already exists at ~/.gemini/commands/gsd/, skip + // the local copy. Gemini conflict-detects by command name across + // scopes and renames every overlapping /gsd:* command to + // /workspace.gsd:* and /user.gsd:*, breaking the documented namespace. + // The user-scope install already provides the same commands, so the + // local copy adds zero value at the cost of namespace conflicts. + // + // CR #3041 (Major): the detection must be specific to PACKAGE-MANAGED + // GSD content, not just "directory is non-empty". A user who hand- + // dropped a single override (e.g. ~/.gemini/commands/gsd/my-override + // .toml) would otherwise be unable to run a local install at all. + // Detection rule: at least 3 of the canonical GSD command files + // ('help.toml', 'progress.toml', 'new-project.toml') must be present. + // These three ship in every GSD Gemini install (minimal mode included + // — they're in the core skill set per #2790's consolidation), and 3-of- + // 3 with that specific basename set is structurally impossible to + // produce by accident. + const homeGeminiGsd = path.join(os.homedir(), '.gemini', 'commands', 'gsd'); + const GSD_MANAGED_CANARIES = ['help.toml', 'progress.toml', 'new-project.toml']; + const userScopeHasGsd = + !isGlobal && + path.resolve(targetDir) !== path.resolve(path.join(os.homedir(), '.gemini')) && + fs.existsSync(homeGeminiGsd) && + GSD_MANAGED_CANARIES.every((f) => + fs.existsSync(path.join(homeGeminiGsd, f)) + ); + + if (userScopeHasGsd) { + console.log( + ` ${yellow}⚠${reset} Skipping commands/gsd/ for local install — GSD is already installed at user scope (${homeGeminiGsd}).` + ); + console.log( + ` Gemini conflict-detects across scopes and would rename every /gsd:* command to /workspace.gsd:* and /user.gsd:*.` + ); + console.log( + ` The user-scope install already provides /gsd:* commands in this project; no local copy is needed.` + ); } else { - failures.push('commands/gsd'); + const commandsDir = path.join(targetDir, 'commands'); + fs.mkdirSync(commandsDir, { recursive: true }); + const gsdSrc = stageSkillsForMode(path.join(src, 'commands', 'gsd'), installMode); + const gsdDest = path.join(commandsDir, 'gsd'); + copyWithPathReplacement(gsdSrc, gsdDest, pathPrefix, runtime, true, isGlobal); + if (verifyInstalled(gsdDest, 'commands/gsd')) { + console.log(` ${green}✓${reset} Installed commands/gsd`); + } else { + failures.push('commands/gsd'); + } } } else if (isGlobal) { // Claude Code global: skills/ format (2.1.88+ compatibility) diff --git a/tests/bug-3037-gemini-duplicate-commands.test.cjs b/tests/bug-3037-gemini-duplicate-commands.test.cjs new file mode 100644 index 000000000..92a40b564 --- /dev/null +++ b/tests/bug-3037-gemini-duplicate-commands.test.cjs @@ -0,0 +1,185 @@ +/** + * Bug #3037: Gemini global+local install creates duplicate /gsd:* commands + * across user (HOME/.gemini/) and workspace (PROJECT/.gemini/) scopes. + * + * Reproduction (from issue body): + * 1. install --gemini --global with HOME=tmpHome + * 2. cd tmpProject; install --gemini --local + * → both ~/.gemini/commands/gsd/ and PROJECT/.gemini/commands/gsd/ contain + * 65 overlapping command filenames. + * → Gemini conflict detection renames every overlapping command to + * /workspace.gsd:* and /user.gsd:*, breaking the documented /gsd:* + * namespace. + * + * Fix: when the local Gemini install detects the user-scope GSD command + * directory already exists with managed-shape content, skip the local copy + * and emit a clear warning explaining the conflict avoidance. + * + * Tests are structural: they assert on the post-install filesystem shape + * (existence and overlap count of typed paths), not on warning-message + * substrings. + */ + +'use strict'; + +process.env.GSD_TEST_MODE = '1'; + +const { test, describe, beforeEach, afterEach } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const os = require('node:os'); +const { createTempDir, cleanup } = require('./helpers.cjs'); + +const { install } = require('../bin/install.js'); + +describe('bug #3037: Gemini global+local install must not create duplicate command scopes', () => { + let tmpHome; + let tmpProject; + let originalHome; + let originalUserprofile; + let originalCwd; + + beforeEach(() => { + tmpHome = createTempDir('gsd-3037-home-'); + tmpProject = createTempDir('gsd-3037-work-'); + originalHome = process.env.HOME; + originalUserprofile = process.env.USERPROFILE; + originalCwd = process.cwd(); + // Point HOME at the temp dir so install(true, 'gemini') writes to + // tmpHome/.gemini, not the developer's real home. + process.env.HOME = tmpHome; + process.env.USERPROFILE = tmpHome; + }); + + afterEach(() => { + if (originalHome === undefined) delete process.env.HOME; + else process.env.HOME = originalHome; + // CR #3041: also restore USERPROFILE so the temp HOME doesn't leak + // into later tests and create order-dependent failures on Windows + // or any code path that reads USERPROFILE. + if (originalUserprofile === undefined) delete process.env.USERPROFILE; + else process.env.USERPROFILE = originalUserprofile; + process.chdir(originalCwd); + cleanup(tmpHome); + cleanup(tmpProject); + }); + + function listCommandFiles(geminiCommandsRoot) { + if (!fs.existsSync(geminiCommandsRoot)) return []; + const out = []; + function walk(dir) { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) walk(full); + else if (entry.isFile()) out.push(path.relative(geminiCommandsRoot, full)); + } + } + walk(geminiCommandsRoot); + return out.sort(); + } + + test('global install populates HOME/.gemini/commands/gsd', () => { + install(true, 'gemini'); + const globalCmds = path.join(tmpHome, '.gemini', 'commands', 'gsd'); + const files = listCommandFiles(globalCmds); + assert.ok( + files.length > 0, + 'global install must populate HOME/.gemini/commands/gsd' + ); + }); + + test('local install after global does NOT populate PROJECT/.gemini/commands/gsd (avoids /gsd:* namespace conflict)', () => { + // Step 1: global install + install(true, 'gemini'); + const globalCmds = path.join(tmpHome, '.gemini', 'commands', 'gsd'); + const globalFiles = listCommandFiles(globalCmds); + assert.ok(globalFiles.length > 0, 'precondition: global install must succeed'); + + // Step 2: local install in a temp project + process.chdir(tmpProject); + install(false, 'gemini'); + + // Assertion: the local commands/gsd/ directory must NOT exist (or must + // be empty) so Gemini's conflict detection has nothing to rename. The + // fix may either skip the directory entirely (preferred — no leftover + // file system noise) or create an empty directory (acceptable but odd). + const localCmds = path.join(tmpProject, '.gemini', 'commands', 'gsd'); + const localFiles = listCommandFiles(localCmds); + assert.equal( + localFiles.length, + 0, + `local install must skip commands/gsd/ when global already exists; ` + + `found ${localFiles.length} duplicate command file(s) at ${localCmds}` + ); + }); + + test('local install with NO existing global GSD does still populate PROJECT/.gemini/commands/gsd', () => { + // No global install first — local should proceed normally so users who + // only ever run --local still get GSD commands in their project. + process.chdir(tmpProject); + install(false, 'gemini'); + + const localCmds = path.join(tmpProject, '.gemini', 'commands', 'gsd'); + const localFiles = listCommandFiles(localCmds); + assert.ok( + localFiles.length > 0, + `local-only install must populate PROJECT/.gemini/commands/gsd; ` + + `found ${localFiles.length} files at ${localCmds}` + ); + }); + + test('local install when HOME has hand-dropped overrides UNDER commands/gsd/ (but no full GSD) still populates locally', () => { + // CR #3041 regression: the previous detection was + // `fs.readdirSync(homeGeminiGsd).length > 0` which would skip the + // local install for a user who manually dropped a single override + // command at ~/.gemini/commands/gsd/.toml without ever + // running --gemini --global. The fix narrows detection to require + // at least 3 canonical GSD command files (help.toml, progress.toml, + // new-project.toml) — a marker that's structurally impossible to + // produce by accident. + const homeGsdDir = path.join(tmpHome, '.gemini', 'commands', 'gsd'); + fs.mkdirSync(homeGsdDir, { recursive: true }); + fs.writeFileSync( + path.join(homeGsdDir, 'my-override.toml'), + 'description = "user override"\nprompt = "..."\n' + ); + + process.chdir(tmpProject); + install(false, 'gemini'); + + const localCmds = path.join(tmpProject, '.gemini', 'commands', 'gsd'); + const localFiles = listCommandFiles(localCmds); + assert.ok( + localFiles.length > 0, + `local install must proceed when HOME/.gemini/commands/gsd contains ` + + `only user overrides (not the full GSD canary set); ` + + `found ${localFiles.length} files at ${localCmds}` + ); + }); + + test('local install when HOME/.gemini exists but commands/gsd is absent (non-GSD Gemini user) still populates locally', () => { + // Simulate a user who has Gemini configured but never installed GSD + // globally. ~/.gemini/ exists with unrelated content; ~/.gemini/commands/ + // may or may not exist with non-gsd subdirectories. Local install must + // still proceed because no GSD-managed user-scope directory is present. + fs.mkdirSync(path.join(tmpHome, '.gemini', 'commands', 'someone-else'), { + recursive: true, + }); + fs.writeFileSync( + path.join(tmpHome, '.gemini', 'commands', 'someone-else', 'foo.toml'), + 'description = "user command"\nprompt = "..."\n' + ); + + process.chdir(tmpProject); + install(false, 'gemini'); + + const localCmds = path.join(tmpProject, '.gemini', 'commands', 'gsd'); + const localFiles = listCommandFiles(localCmds); + assert.ok( + localFiles.length > 0, + `local install must proceed when no GSD-managed user-scope directory ` + + `exists, even if other Gemini commands are present at the user scope` + ); + }); +}); diff --git a/tests/gemini-namespacing.test.cjs b/tests/gemini-namespacing.test.cjs index 213f991c4..04383225d 100644 --- a/tests/gemini-namespacing.test.cjs +++ b/tests/gemini-namespacing.test.cjs @@ -136,17 +136,34 @@ describe('Gemini Markdown Processor', () => { describe('Gemini Install (Behavioral)', () => { let tmpDir; + let tmpHome; let previousCwd; + let previousHome; + let previousUserprofile; beforeEach(() => { tmpDir = createTempDir('gsd-gemini-test-'); + tmpHome = createTempDir('gsd-gemini-home-'); previousCwd = process.cwd(); + previousHome = process.env.HOME; + previousUserprofile = process.env.USERPROFILE; process.chdir(tmpDir); + // #3037: isolate HOME so the developer's real ~/.gemini/commands/gsd/ + // doesn't trigger the local-install conflict-avoidance skip path. This + // test wants to assert that the local install populates commands/gsd/ + // when no global GSD is present at the user scope. + process.env.HOME = tmpHome; + process.env.USERPROFILE = tmpHome; }); afterEach(() => { process.chdir(previousCwd); + if (previousHome === undefined) delete process.env.HOME; + else process.env.HOME = previousHome; + if (previousUserprofile === undefined) delete process.env.USERPROFILE; + else process.env.USERPROFILE = previousUserprofile; cleanup(tmpDir); + cleanup(tmpHome); }); test('install creates correct directory structure for Gemini', () => { diff --git a/tests/install-minimal-all-runtimes.test.cjs b/tests/install-minimal-all-runtimes.test.cjs index bff5e55ea..8d109e20b 100644 --- a/tests/install-minimal-all-runtimes.test.cjs +++ b/tests/install-minimal-all-runtimes.test.cjs @@ -106,6 +106,13 @@ function runInstall({ runtime, scope, extraArgs = [] }) { const result = spawnSync(process.execPath, args, { cwd, encoding: 'utf8', + // #3037: isolate HOME so the developer's real ~/.gemini/commands/gsd/ + // doesn't leak into Gemini local-install conflict detection. The + // installer reads os.homedir() to detect prior global GSD installs; + // without this, the dev's existing global install causes the local + // install to skip (correct behavior for end users, wrong for tests + // that want to assert the local install path). + env: { ...process.env, HOME: root, USERPROFILE: root }, }); assert.strictEqual(