diff --git a/bin/gstack-repo-mode b/bin/gstack-repo-mode index 0b4d6da64..0aabe378e 100755 --- a/bin/gstack-repo-mode +++ b/bin/gstack-repo-mode @@ -12,13 +12,20 @@ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" -# Compute SLUG directly (avoid eval of gstack-slug — branch names can contain shell metacharacters) +# Keep the no-remote early exit before consulting gstack-slug. gstack-slug falls +# back to the directory basename when origin is missing, which would incorrectly +# make local-only repos eligible for classification. REMOTE_URL=$(git remote get-url origin 2>/dev/null || true) if [ -z "$REMOTE_URL" ]; then echo "REPO_MODE=unknown" exit 0 fi -SLUG=$(echo "$REMOTE_URL" | sed 's|.*[:/]\([^/]*/[^/]*\)\.git$|\1|;s|.*[:/]\([^/]*/[^/]*\)$|\1|' | tr '/' '-') +# Consume the canonical cached slug from gstack-slug (sed, never eval — branch +# names can contain shell metacharacters). Invoke from the git root so the +# PWD-keyed slug cache matches other gstack project-state writers. +REPO_ROOT=$(git rev-parse --show-toplevel 2>/dev/null || true) +[ -z "${REPO_ROOT:-}" ] && { echo "REPO_MODE=unknown"; exit 0; } +SLUG=$(cd "$REPO_ROOT" && "$SCRIPT_DIR/gstack-slug" | sed -n 's/^SLUG=//p' | head -1) [ -z "${SLUG:-}" ] && { echo "REPO_MODE=unknown"; exit 0; } # Validate: only allow known values (prevent shell injection via source <(...)) diff --git a/test/gstack-repo-mode.test.ts b/test/gstack-repo-mode.test.ts new file mode 100644 index 000000000..62088a217 --- /dev/null +++ b/test/gstack-repo-mode.test.ts @@ -0,0 +1,176 @@ +/** + * Project-slug consistency for gstack-repo-mode. + * + * These are end-to-end reproductions of #2212. Each case is a real local Git + * workspace which predates its public GitHub origin: gstack-slug caches the + * local directory name, then the origin is added. repo-mode must write its + * cache next to that canonical project state, not beside a newly derived + * owner-repo slug. + */ + +import { describe, expect, test } from 'bun:test'; +import { spawnSync } from 'child_process'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; + +const ROOT = path.resolve(import.meta.dir, '..'); +const REPO_MODE_BIN = path.join(ROOT, 'bin', 'gstack-repo-mode'); +const SLUG_BIN = path.join(ROOT, 'bin', 'gstack-slug'); + +type CommandResult = { stdout: string; stderr: string; status: number }; + +function run(command: string, args: string[], cwd: string, home: string): CommandResult { + const result = spawnSync(command, args, { + cwd, + encoding: 'utf8', + env: { ...process.env, HOME: home, GSTACK_HOME: path.join(home, '.gstack') }, + }); + return { + stdout: result.stdout ?? '', + stderr: result.stderr ?? '', + status: result.status ?? -1, + }; +} + +function git(args: string[], cwd: string, home: string) { + const result = run('git', args, cwd, home); + expect(result.status, `${result.stderr}\n${result.stdout}`).toBe(0); +} + +function slugFrom(output: string): string { + const line = output.split('\n').find((entry) => entry.startsWith('SLUG=')); + expect(line).toBeDefined(); + return line!.slice('SLUG='.length); +} + +const publicWorkspaces = [ + { name: 'react-local-history', origin: 'https://github.com/facebook/react.git' }, + { name: 'next-local-history', origin: 'https://github.com/vercel/next.js.git' }, + { name: 'kubernetes-local-history', origin: 'https://github.com/kubernetes/kubernetes.git' }, +]; + +describe('gstack-repo-mode cached slug consistency (#2212)', () => { + for (const workspace of publicWorkspaces) { + test(`${workspace.origin} keeps its pre-origin project cache`, () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), 'grepo-home-')); + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'grepo-workspaces-')); + const project = path.join(root, workspace.name); + + try { + fs.mkdirSync(project); + git(['init', '-b', 'main'], project, home); + git(['config', 'user.name', 'gstack regression test'], project, home); + git(['config', 'user.email', 'test@gstack.dev'], project, home); + + // This workspace existed and used gstack before it adopted GitHub. + const initialSlug = slugFrom(run(SLUG_BIN, [], project, home).stdout); + expect(initialSlug).toBe(workspace.name); + + // Five commits make repo-mode perform its real classification path. + for (let commit = 1; commit <= 5; commit += 1) { + fs.writeFileSync(path.join(project, `commit-${commit}.txt`), String(commit)); + git(['add', '.'], project, home); + git(['commit', '-m', `commit ${commit}`], project, home); + } + git(['remote', 'add', 'origin', workspace.origin], project, home); + + const mode = run(REPO_MODE_BIN, [], project, home); + expect(mode.status, mode.stderr).toBe(0); + expect(mode.stdout.trim()).toBe('REPO_MODE=solo'); + + // Canonical slug stays the pre-origin basename; remote-derived twin must not appear. + const projectsDir = path.join(home, '.gstack', 'projects'); + const canonicalCache = path.join(projectsDir, initialSlug, 'repo-mode.json'); + expect(fs.existsSync(canonicalCache)).toBe(true); + expect(fs.readdirSync(projectsDir)).toEqual([initialSlug]); + + // Slug remains sanitized even when the remote URL would otherwise diverge. + expect(initialSlug).toMatch(/^[a-zA-Z0-9._-]+$/); + expect(slugFrom(run(SLUG_BIN, [], project, home).stdout)).toBe(initialSlug); + } finally { + fs.rmSync(home, { recursive: true, force: true }); + fs.rmSync(root, { recursive: true, force: true }); + } + }); + } + + test('still treats a workspace without origin as unknown', () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), 'grepo-home-')); + const project = fs.mkdtempSync(path.join(os.tmpdir(), 'grepo-local-only-')); + try { + git(['init', '-b', 'main'], project, home); + const mode = run(REPO_MODE_BIN, [], project, home); + expect(mode.status, mode.stderr).toBe(0); + expect(mode.stdout.trim()).toBe('REPO_MODE=unknown'); + expect(fs.existsSync(path.join(home, '.gstack', 'projects'))).toBe(false); + } finally { + fs.rmSync(home, { recursive: true, force: true }); + fs.rmSync(project, { recursive: true, force: true }); + } + }); + + test('subdir invocation still writes under the git-root cached slug', () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), 'grepo-home-')); + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'grepo-subdir-')); + const project = path.join(root, 'nested-history'); + const nested = path.join(project, 'packages', 'app'); + try { + fs.mkdirSync(nested, { recursive: true }); + git(['init', '-b', 'main'], project, home); + git(['config', 'user.name', 'gstack regression test'], project, home); + git(['config', 'user.email', 'test@gstack.dev'], project, home); + + const initialSlug = slugFrom(run(SLUG_BIN, [], project, home).stdout); + expect(initialSlug).toBe('nested-history'); + + for (let commit = 1; commit <= 5; commit += 1) { + fs.writeFileSync(path.join(project, `commit-${commit}.txt`), String(commit)); + git(['add', '.'], project, home); + git(['commit', '-m', `commit ${commit}`], project, home); + } + git(['remote', 'add', 'origin', 'https://github.com/facebook/react.git'], project, home); + + const mode = run(REPO_MODE_BIN, [], nested, home); + expect(mode.status, mode.stderr).toBe(0); + expect(mode.stdout.trim()).toBe('REPO_MODE=solo'); + + const projectsDir = path.join(home, '.gstack', 'projects'); + expect(fs.existsSync(path.join(projectsDir, initialSlug, 'repo-mode.json'))).toBe(true); + expect(fs.readdirSync(projectsDir)).toEqual([initialSlug]); + } finally { + fs.rmSync(home, { recursive: true, force: true }); + fs.rmSync(root, { recursive: true, force: true }); + } + }); + + test('sanitizes unusual origin URLs via gstack-slug before mkdir', () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), 'grepo-home-')); + const project = fs.mkdtempSync(path.join(os.tmpdir(), 'grepo-weird-url-')); + try { + git(['init', '-b', 'main'], project, home); + git(['config', 'user.name', 'gstack regression test'], project, home); + git(['config', 'user.email', 'test@gstack.dev'], project, home); + + for (let commit = 1; commit <= 5; commit += 1) { + fs.writeFileSync(path.join(project, `commit-${commit}.txt`), String(commit)); + git(['add', '.'], project, home); + git(['commit', '-m', `commit ${commit}`], project, home); + } + // Free-form remote URL with characters outside [a-zA-Z0-9._-] + git(['remote', 'add', 'origin', 'https://example.com/org/my repo (v2).git'], project, home); + + const mode = run(REPO_MODE_BIN, [], project, home); + expect(mode.status, mode.stderr).toBe(0); + expect(mode.stdout.trim()).toBe('REPO_MODE=solo'); + + const projects = fs.readdirSync(path.join(home, '.gstack', 'projects')); + expect(projects).toHaveLength(1); + expect(projects[0]).toMatch(/^[a-zA-Z0-9._-]+$/); + expect(projects[0]).not.toMatch(/[ ()]/); + } finally { + fs.rmSync(home, { recursive: true, force: true }); + fs.rmSync(project, { recursive: true, force: true }); + } + }); +});