fix(install): skip Gemini local commands/gsd when global GSD present (#3037) (#3041)

* 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)
  - <project>/.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 <noreply@anthropic.com>

* 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/<thing>
   .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 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Tom Boucher
2026-05-02 17:44:52 -04:00
committed by GitHub
parent 7714b5244b
commit dca12242b5
5 changed files with 262 additions and 8 deletions

View File

@@ -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 `<project>/.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.

View File

@@ -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)

View File

@@ -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/<thing>.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`
);
});
});

View File

@@ -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', () => {

View File

@@ -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(