* 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:
5
.changeset/gemini-skip-local-when-global.md
Normal file
5
.changeset/gemini-skip-local-when-global.md
Normal 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.
|
||||
@@ -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)
|
||||
|
||||
185
tests/bug-3037-gemini-duplicate-commands.test.cjs
Normal file
185
tests/bug-3037-gemini-duplicate-commands.test.cjs
Normal 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`
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user