From b3d064aabb108287d86e017b138484bb2f013fd1 Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Mon, 6 Apr 2026 00:26:20 -0700 Subject: [PATCH] fix: gstack-team-init detects and removes vendored copies (#848) * fix: gstack-team-init detects and removes vendored copies in team mode When running gstack-team-init inside a repo with a vendored .claude/skills/gstack/, the script now auto-detects and removes it: git rm --cached, add to .gitignore, rm -rf. Also adds team_mode config key to setup --team/--no-team, and makes gstack-upgrade Step 4.5 team-mode aware (remove instead of sync). Includes 5 new integration tests for the vendored copy migration. * chore: bump version and changelog (v0.15.14.0) Co-Authored-By: Claude Opus 4.6 --------- Co-authored-by: Claude Opus 4.6 --- CHANGELOG.md | 8 +++++ VERSION | 2 +- bin/gstack-team-init | 20 ++++++++++++ gstack-upgrade/SKILL.md | 26 ++++++++++++--- gstack-upgrade/SKILL.md.tmpl | 26 ++++++++++++--- setup | 2 ++ test/team-mode.test.ts | 63 ++++++++++++++++++++++++++++++++++++ 7 files changed, 136 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 15c2ada9..6275be8d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## [0.15.14.0] - 2026-04-05 + +### Fixed + +- **`gstack-team-init` now detects and removes vendored gstack copies.** When you run `gstack-team-init` inside a repo that has gstack vendored at `.claude/skills/gstack/`, it automatically removes the vendored copy, untracks it from git, and adds it to `.gitignore`. No more stale vendored copies shadowing the global install. +- **`/gstack-upgrade` respects team mode.** Step 4.5 now checks the `team_mode` config. In team mode, vendored copies are removed instead of synced, since the global install is the single source of truth. +- **`team_mode` config key.** `./setup --team` and `./setup --no-team` now set a dedicated `team_mode` config key so the upgrade skill can reliably distinguish team mode from just having auto-upgrade enabled. + ## [0.15.13.0] - 2026-04-04 — Team Mode Teams can now keep every developer on the same gstack version automatically. No more vendoring 342 files into your repo. No more version drift across branches. No more "who upgraded gstack last?" Slack threads. One command, every developer is current. diff --git a/VERSION b/VERSION index 93c34ea4..d37fea57 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.15.13.0 +0.15.14.0 diff --git a/bin/gstack-team-init b/bin/gstack-team-init index 6195c7b6..1fc08ea9 100755 --- a/bin/gstack-team-init +++ b/bin/gstack-team-init @@ -29,6 +29,26 @@ REPO_ROOT=$(git rev-parse --show-toplevel) CLAUDE_MD="$REPO_ROOT/CLAUDE.md" GENERATED=() +# ── Migrate vendored copy if present ────────────────────────── + +if [ -d "$REPO_ROOT/.claude/skills/gstack" ] && [ ! -L "$REPO_ROOT/.claude/skills/gstack" ]; then + if [ -f "$REPO_ROOT/.claude/skills/gstack/VERSION" ] || [ -d "$REPO_ROOT/.claude/skills/gstack/.git" ]; then + echo " Found vendored gstack copy at $REPO_ROOT/.claude/skills/gstack" + echo " Team mode uses the global install — removing vendored copy..." + ( cd "$REPO_ROOT" && git rm -r --cached .claude/skills/gstack/ 2>/dev/null ) || true + if [ -f "$REPO_ROOT/.gitignore" ]; then + if ! grep -qF '.claude/skills/gstack/' "$REPO_ROOT/.gitignore" 2>/dev/null; then + echo '.claude/skills/gstack/' >> "$REPO_ROOT/.gitignore" + fi + else + echo '.claude/skills/gstack/' > "$REPO_ROOT/.gitignore" + fi + rm -rf "$REPO_ROOT/.claude/skills/gstack" + GENERATED+=(".gitignore") + echo " Removed vendored copy and added .claude/skills/gstack/ to .gitignore" + fi +fi + # ── CLAUDE.md snippet ────────────────────────────────────────── if [ "$MODE" = "optional" ]; then diff --git a/gstack-upgrade/SKILL.md b/gstack-upgrade/SKILL.md index 12c3840a..07fe7519 100644 --- a/gstack-upgrade/SKILL.md +++ b/gstack-upgrade/SKILL.md @@ -137,9 +137,9 @@ cd "$INSTALL_DIR" && ./setup rm -rf "$INSTALL_DIR.bak" "$TMP_DIR" ``` -### Step 4.5: Sync local vendored copy +### Step 4.5: Handle local vendored copy -Use the install directory from Step 2. Check if there's also a local vendored copy that needs updating: +Use the install directory from Step 2. Check if there's also a local vendored copy, and whether team mode is active: ```bash _ROOT=$(git rev-parse --show-toplevel 2>/dev/null) @@ -151,10 +151,24 @@ if [ -n "$_ROOT" ] && [ -d "$_ROOT/.claude/skills/gstack" ]; then LOCAL_GSTACK="$_ROOT/.claude/skills/gstack" fi fi +_TEAM_MODE=$(~/.claude/skills/gstack/bin/gstack-config get team_mode 2>/dev/null || echo "false") echo "LOCAL_GSTACK=$LOCAL_GSTACK" +echo "TEAM_MODE=$_TEAM_MODE" ``` -If `LOCAL_GSTACK` is non-empty, update it by copying from the freshly-upgraded primary install (same approach as README vendored install): +**If `LOCAL_GSTACK` is non-empty AND `TEAM_MODE` is `true`:** Remove the vendored copy. Team mode uses the global install as the single source of truth. + +```bash +cd "$_ROOT" +git rm -r --cached .claude/skills/gstack/ 2>/dev/null || true +if ! grep -qF '.claude/skills/gstack/' .gitignore 2>/dev/null; then + echo '.claude/skills/gstack/' >> .gitignore +fi +rm -rf "$LOCAL_GSTACK" +``` +Tell user: "Removed vendored copy at `$LOCAL_GSTACK` (team mode active — global install is the source of truth). Commit the `.gitignore` change when ready." + +**If `LOCAL_GSTACK` is non-empty AND `TEAM_MODE` is NOT `true`:** Update it by copying from the freshly-upgraded primary install (same approach as README vendored install): ```bash mv "$LOCAL_GSTACK" "$LOCAL_GSTACK.bak" cp -Rf "$INSTALL_DIR" "$LOCAL_GSTACK" @@ -243,11 +257,13 @@ Use the output to determine if an upgrade is available. 3. If no output (primary is up to date): check for a stale local vendored copy. -Run the Step 2 bash block above to detect the primary install type and directory (`INSTALL_TYPE` and `INSTALL_DIR`). Then run the Step 4.5 detection bash block above to check for a local vendored copy (`LOCAL_GSTACK`). +Run the Step 2 bash block above to detect the primary install type and directory (`INSTALL_TYPE` and `INSTALL_DIR`). Then run the Step 4.5 detection bash block above to check for a local vendored copy (`LOCAL_GSTACK`) and team mode status (`TEAM_MODE`). **If `LOCAL_GSTACK` is empty** (no local vendored copy): tell the user "You're already on the latest version (v{version})." -**If `LOCAL_GSTACK` is non-empty**, compare versions: +**If `LOCAL_GSTACK` is non-empty AND `TEAM_MODE` is `true`:** Remove the vendored copy using the Step 4.5 team-mode removal bash block above. Tell user: "Global v{version} is up to date. Removed stale vendored copy (team mode active). Commit the `.gitignore` change when ready." + +**If `LOCAL_GSTACK` is non-empty AND `TEAM_MODE` is NOT `true`**, compare versions: ```bash PRIMARY_VER=$(cat "$INSTALL_DIR/VERSION" 2>/dev/null || echo "unknown") LOCAL_VER=$(cat "$LOCAL_GSTACK/VERSION" 2>/dev/null || echo "unknown") diff --git a/gstack-upgrade/SKILL.md.tmpl b/gstack-upgrade/SKILL.md.tmpl index 9e85478a..af4bcd23 100644 --- a/gstack-upgrade/SKILL.md.tmpl +++ b/gstack-upgrade/SKILL.md.tmpl @@ -139,9 +139,9 @@ cd "$INSTALL_DIR" && ./setup rm -rf "$INSTALL_DIR.bak" "$TMP_DIR" ``` -### Step 4.5: Sync local vendored copy +### Step 4.5: Handle local vendored copy -Use the install directory from Step 2. Check if there's also a local vendored copy that needs updating: +Use the install directory from Step 2. Check if there's also a local vendored copy, and whether team mode is active: ```bash _ROOT=$(git rev-parse --show-toplevel 2>/dev/null) @@ -153,10 +153,24 @@ if [ -n "$_ROOT" ] && [ -d "$_ROOT/.claude/skills/gstack" ]; then LOCAL_GSTACK="$_ROOT/.claude/skills/gstack" fi fi +_TEAM_MODE=$(~/.claude/skills/gstack/bin/gstack-config get team_mode 2>/dev/null || echo "false") echo "LOCAL_GSTACK=$LOCAL_GSTACK" +echo "TEAM_MODE=$_TEAM_MODE" ``` -If `LOCAL_GSTACK` is non-empty, update it by copying from the freshly-upgraded primary install (same approach as README vendored install): +**If `LOCAL_GSTACK` is non-empty AND `TEAM_MODE` is `true`:** Remove the vendored copy. Team mode uses the global install as the single source of truth. + +```bash +cd "$_ROOT" +git rm -r --cached .claude/skills/gstack/ 2>/dev/null || true +if ! grep -qF '.claude/skills/gstack/' .gitignore 2>/dev/null; then + echo '.claude/skills/gstack/' >> .gitignore +fi +rm -rf "$LOCAL_GSTACK" +``` +Tell user: "Removed vendored copy at `$LOCAL_GSTACK` (team mode active — global install is the source of truth). Commit the `.gitignore` change when ready." + +**If `LOCAL_GSTACK` is non-empty AND `TEAM_MODE` is NOT `true`:** Update it by copying from the freshly-upgraded primary install (same approach as README vendored install): ```bash mv "$LOCAL_GSTACK" "$LOCAL_GSTACK.bak" cp -Rf "$INSTALL_DIR" "$LOCAL_GSTACK" @@ -245,11 +259,13 @@ Use the output to determine if an upgrade is available. 3. If no output (primary is up to date): check for a stale local vendored copy. -Run the Step 2 bash block above to detect the primary install type and directory (`INSTALL_TYPE` and `INSTALL_DIR`). Then run the Step 4.5 detection bash block above to check for a local vendored copy (`LOCAL_GSTACK`). +Run the Step 2 bash block above to detect the primary install type and directory (`INSTALL_TYPE` and `INSTALL_DIR`). Then run the Step 4.5 detection bash block above to check for a local vendored copy (`LOCAL_GSTACK`) and team mode status (`TEAM_MODE`). **If `LOCAL_GSTACK` is empty** (no local vendored copy): tell the user "You're already on the latest version (v{version})." -**If `LOCAL_GSTACK` is non-empty**, compare versions: +**If `LOCAL_GSTACK` is non-empty AND `TEAM_MODE` is `true`:** Remove the vendored copy using the Step 4.5 team-mode removal bash block above. Tell user: "Global v{version} is up to date. Removed stale vendored copy (team mode active). Commit the `.gitignore` change when ready." + +**If `LOCAL_GSTACK` is non-empty AND `TEAM_MODE` is NOT `true`**, compare versions: ```bash PRIMARY_VER=$(cat "$INSTALL_DIR/VERSION" 2>/dev/null || echo "unknown") LOCAL_VER=$(cat "$LOCAL_GSTACK/VERSION" 2>/dev/null || echo "unknown") diff --git a/setup b/setup index 65da9496..ca97764d 100755 --- a/setup +++ b/setup @@ -785,6 +785,7 @@ HOOK_CMD="$SOURCE_GSTACK_DIR/bin/gstack-session-update" if [ "$TEAM_MODE" -eq 1 ]; then "$GSTACK_CONFIG" set auto_upgrade true 2>/dev/null || true + "$GSTACK_CONFIG" set team_mode true 2>/dev/null || true # Register SessionStart hook in Claude Code settings if [ -x "$SETTINGS_HOOK" ]; then @@ -802,6 +803,7 @@ fi if [ "$NO_TEAM_MODE" -eq 1 ]; then "$GSTACK_CONFIG" set auto_upgrade false 2>/dev/null || true + "$GSTACK_CONFIG" set team_mode false 2>/dev/null || true # Remove SessionStart hook from Claude Code settings if [ -x "$SETTINGS_HOOK" ]; then diff --git a/test/team-mode.test.ts b/test/team-mode.test.ts index e2b030fb..660f6687 100644 --- a/test/team-mode.test.ts +++ b/test/team-mode.test.ts @@ -257,6 +257,69 @@ describe('gstack-team-init', () => { const matches = claude.match(/## gstack/g); expect(matches).toHaveLength(1); }); + + test('removes vendored copy when present', () => { + // Create a fake vendored gstack with VERSION file + const vendoredDir = path.join(tmpDir, '.claude', 'skills', 'gstack'); + fs.mkdirSync(vendoredDir, { recursive: true }); + fs.writeFileSync(path.join(vendoredDir, 'VERSION'), '0.14.0.0'); + fs.writeFileSync(path.join(vendoredDir, 'README.md'), 'vendored'); + // Track it in git + execSync('git add .claude/skills/gstack/', { cwd: tmpDir }); + execSync('git commit -m "add vendored gstack"', { cwd: tmpDir }); + + const result = run(`${TEAM_INIT} optional`, { cwd: tmpDir }); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain('Found vendored gstack copy'); + expect(result.stdout).toContain('Removed vendored copy'); + // Vendored dir should be gone + expect(fs.existsSync(vendoredDir)).toBe(false); + // .gitignore should have the entry + const gitignore = fs.readFileSync(path.join(tmpDir, '.gitignore'), 'utf-8'); + expect(gitignore).toContain('.claude/skills/gstack/'); + }); + + test('skips when no vendored copy present', () => { + const result = run(`${TEAM_INIT} optional`, { cwd: tmpDir }); + expect(result.exitCode).toBe(0); + expect(result.stdout).not.toContain('Found vendored gstack copy'); + }); + + test('skips when .claude/skills/gstack is a symlink', () => { + // Create a symlink (not a real vendored copy) + const skillsDir = path.join(tmpDir, '.claude', 'skills'); + fs.mkdirSync(skillsDir, { recursive: true }); + const targetDir = mkTmpDir(); + fs.writeFileSync(path.join(targetDir, 'VERSION'), '0.14.0.0'); + fs.symlinkSync(targetDir, path.join(skillsDir, 'gstack')); + + const result = run(`${TEAM_INIT} optional`, { cwd: tmpDir }); + expect(result.exitCode).toBe(0); + expect(result.stdout).not.toContain('Found vendored gstack copy'); + // Symlink should still exist + expect(fs.lstatSync(path.join(skillsDir, 'gstack')).isSymbolicLink()).toBe(true); + fs.rmSync(targetDir, { recursive: true, force: true }); + }); + + test('does not duplicate .gitignore entry on re-run', () => { + // Create vendored copy + const vendoredDir = path.join(tmpDir, '.claude', 'skills', 'gstack'); + fs.mkdirSync(vendoredDir, { recursive: true }); + fs.writeFileSync(path.join(vendoredDir, 'VERSION'), '0.14.0.0'); + execSync('git add .claude/skills/gstack/', { cwd: tmpDir }); + execSync('git commit -m "add vendored"', { cwd: tmpDir }); + + run(`${TEAM_INIT} optional`, { cwd: tmpDir }); + + // Re-create vendored dir to simulate re-run scenario + fs.mkdirSync(vendoredDir, { recursive: true }); + fs.writeFileSync(path.join(vendoredDir, 'VERSION'), '0.14.0.0'); + run(`${TEAM_INIT} optional`, { cwd: tmpDir }); + + const gitignore = fs.readFileSync(path.join(tmpDir, '.gitignore'), 'utf-8'); + const matches = gitignore.match(/\.claude\/skills\/gstack\//g); + expect(matches).toHaveLength(1); + }); }); describe('setup --team / --no-team / -q', () => {