diff --git a/packages/squad-cli/package.json b/packages/squad-cli/package.json index a67125734..b2d663fef 100644 --- a/packages/squad-cli/package.json +++ b/packages/squad-cli/package.json @@ -72,6 +72,10 @@ "types": "./dist/cli/shell/shell-metrics.d.ts", "import": "./dist/cli/shell/shell-metrics.js" }, + "./core/detect-squad-dir": { + "types": "./dist/cli/core/detect-squad-dir.d.ts", + "import": "./dist/cli/core/detect-squad-dir.js" + }, "./core/init": { "types": "./dist/cli/core/init.d.ts", "import": "./dist/cli/core/init.js" diff --git a/packages/squad-cli/src/cli/core/detect-squad-dir.ts b/packages/squad-cli/src/cli/core/detect-squad-dir.ts index d46ea6e85..3b1c31c49 100644 --- a/packages/squad-cli/src/cli/core/detect-squad-dir.ts +++ b/packages/squad-cli/src/cli/core/detect-squad-dir.ts @@ -11,19 +11,66 @@ export interface SquadDirInfo { isLegacy: boolean; } +/** + * If `dir` has a `.git` worktree pointer file, parse it and return the + * absolute path of the main checkout. Returns `null` otherwise. + * + * The `.git` file format is: `gitdir: ` + */ +export function resolveWorktreeMainCheckout(dir: string): string | null { + const gitPath = path.join(dir, '.git'); + try { + const stat = fs.statSync(gitPath); + if (stat.isDirectory()) return null; + const content = fs.readFileSync(gitPath, 'utf-8').trim(); + const match = content.match(/^gitdir:\s*(.+)$/m); + if (!match || !match[1]) return null; + const worktreeGitDir = path.resolve(dir, match[1].trim()); + // worktreeGitDir = /main/.git/worktrees/name + // mainGitDir = /main/.git (up 2 levels) + // mainCheckout = /main (dirname of mainGitDir) + const mainGitDir = path.resolve(worktreeGitDir, '..', '..'); + const mainCheckout = path.dirname(mainGitDir); + // Verify the derived main checkout is a real git repo + if (!fs.existsSync(mainGitDir) || !fs.statSync(mainGitDir).isDirectory()) { + return null; + } + return mainCheckout; + } catch { + return null; + } +} + /** * Detect squad directory — .squad/ first, fall back to .ai-team/ + * + * Worktree-aware: when neither directory exists at `dest`, checks if `dest` + * is a git worktree and looks in the main checkout as a fallback. */ export function detectSquadDir(dest: string): SquadDirInfo { const squadDir = path.join(dest, '.squad'); const aiTeamDir = path.join(dest, '.ai-team'); - + if (fs.existsSync(squadDir)) { return { path: squadDir, name: '.squad', isLegacy: false }; } if (fs.existsSync(aiTeamDir)) { return { path: aiTeamDir, name: '.ai-team', isLegacy: true }; } + + // Worktree fallback: check the main checkout when dest has a .git pointer file + const mainCheckout = resolveWorktreeMainCheckout(dest); + if (mainCheckout) { + const mainSquadDir = path.join(mainCheckout, '.squad'); + const mainAiTeamDir = path.join(mainCheckout, '.ai-team'); + if (fs.existsSync(mainSquadDir)) { + return { path: mainSquadDir, name: '.squad', isLegacy: false }; + } + if (fs.existsSync(mainAiTeamDir)) { + return { path: mainAiTeamDir, name: '.ai-team', isLegacy: true }; + } + } + // Default for new installations return { path: squadDir, name: '.squad', isLegacy: false }; } diff --git a/packages/squad-cli/src/cli/core/init.ts b/packages/squad-cli/src/cli/core/init.ts index f99602bf3..bab5f0b53 100644 --- a/packages/squad-cli/src/cli/core/init.ts +++ b/packages/squad-cli/src/cli/core/init.ts @@ -6,7 +6,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { execFileSync } from 'node:child_process'; -import { detectSquadDir } from './detect-squad-dir.js'; +import { detectSquadDir, resolveWorktreeMainCheckout } from './detect-squad-dir.js'; import { success, BOLD, RESET, YELLOW, GREEN, DIM } from './output.js'; import { fatal } from './errors.js'; import { detectProjectType } from './project-type.js'; @@ -152,6 +152,51 @@ export async function runInit(dest: string, options: RunInitOptions = {}): Promi // Detect squad directory const squadInfo = detectSquadDir(dest); + // ── Worktree guard ──────────────────────────────────────────────── + // Prevent silently scaffolding a duplicate .squad/ when running init + // from a git worktree that already has .squad/ in the main checkout. + const mainCheckout = resolveWorktreeMainCheckout(dest); + if (mainCheckout) { + const mainSquadDir = path.join(mainCheckout, '.squad'); + if (fs.existsSync(mainSquadDir)) { + console.log(); + console.log(`${YELLOW}${BOLD}⚠ Git worktree detected${RESET}`); + console.log(`${YELLOW} Main checkout: ${mainCheckout}${RESET}`); + console.log(`${YELLOW} .squad/ already exists there.${RESET}`); + console.log(); + console.log(` ${BOLD}[s]${RESET} Use shared .squad/ from main checkout ${DIM}(recommended)${RESET}`); + console.log(` ${BOLD}[l]${RESET} Create a worktree-local .squad/ in this branch`); + console.log(); + + let useShared = true; + if (process.stdin.isTTY) { + const { createInterface } = await import('node:readline/promises'); + const rl = createInterface({ input: process.stdin, output: process.stdout }); + try { + const answer = (await rl.question(` Strategy [s/l, default: s]: `)).trim().toLowerCase(); + useShared = answer !== 'l' && answer !== 'local'; + } finally { + rl.close(); + } + } else { + console.log(` ${DIM}Non-interactive mode — defaulting to shared strategy.${RESET}`); + } + + if (useShared) { + console.log(); + console.log(`${GREEN}${BOLD}✓${RESET} Using shared .squad/ from ${mainCheckout}`); + console.log(`${DIM} No changes made. Run squad commands from the main checkout.${RESET}`); + console.log(); + return; + } + + // Local strategy: fall through to scaffold .squad/ in this worktree + console.log(); + console.log(`${GREEN}${BOLD}→${RESET} Creating worktree-local .squad/ in ${dest}`); + console.log(); + } + } + // Show deprecation warning if using .ai-team/ if (squadInfo.isLegacy) { showDeprecationWarning(); diff --git a/packages/squad-sdk/src/resolution.ts b/packages/squad-sdk/src/resolution.ts index cbc3acefe..e5f075a0f 100644 --- a/packages/squad-sdk/src/resolution.ts +++ b/packages/squad-sdk/src/resolution.ts @@ -52,13 +52,49 @@ export interface ResolvedSquadPaths { isLegacy: boolean; } +/** + * Given a directory containing a `.git` worktree pointer file, parse the file + * to derive the absolute path of the main checkout. + * + * The `.git` file format is: `gitdir: ` + * The main checkout is: dirname(dirname(dirname(resolvedGitdir))) — i.e. two levels up + * from the gitdir path puts us at the shared `.git/` dir, and one more dirname gives + * us the main working tree root. + * + * @returns Absolute path to the main working tree, or `null` if resolution fails. + */ +function getMainWorktreePath(worktreeDir: string, gitFilePath: string): string | null { + try { + const content = fs.readFileSync(gitFilePath, 'utf-8').trim(); + const match = content.match(/^gitdir:\s*(.+)$/m); + if (!match || !match[1]) return null; + // worktreeGitDir = /main/.git/worktrees/name + const worktreeGitDir = path.resolve(worktreeDir, match[1].trim()); + // mainGitDir = /main/.git (up 2 from worktreeGitDir) + const mainGitDir = path.resolve(worktreeGitDir, '..', '..'); + // mainCheckout = /main (dirname of mainGitDir) + const mainCheckout = path.dirname(mainGitDir); + // Verify the derived main checkout is a real git repo + if (!fs.existsSync(mainGitDir) || !fs.statSync(mainGitDir).isDirectory()) { + return null; + } + return mainCheckout; + } catch { + return null; + } +} + /** * Walk up the directory tree from `startDir` looking for a `.squad/` directory. * - * Stops at the repository root (the directory containing `.git`). + * Stops at the repository root (the directory containing `.git` as a directory). + * When `.git` is a **file** (git worktree), falls back to the main checkout strategy: + * reads the `gitdir:` pointer, resolves the main checkout path, and checks there. * Returns the **absolute path** to the `.squad/` directory, or `null` if none is found. * - * Handles nested repos, worktrees (`.git` file pointing elsewhere), and symlinks. + * Resolution order (worktree-local strategy first, main-checkout strategy second): + * 1. Walk up from `startDir` checking for `.squad/` — stops at `.git` directory boundary + * 2. If `.git` is a file (worktree), check the main checkout for `.squad/` * * @param startDir - Directory to start searching from. Defaults to `process.cwd()`. * @returns Absolute path to `.squad/` or `null`. @@ -74,10 +110,21 @@ export function resolveSquad(startDir?: string): string | null { return candidate; } - // Stop if we hit a .git boundary (directory or worktree file) const gitMarker = path.join(current, '.git'); if (fs.existsSync(gitMarker)) { - // We've reached the repo root — don't walk higher + if (fs.statSync(gitMarker).isDirectory()) { + // Real repo root — stop walking, no .squad/ found in this checkout + return null; + } + // .git is a file — this is a git worktree + // Worktree-local .squad/ was already checked above; fall back to main checkout + const mainCheckout = getMainWorktreePath(current, gitMarker); + if (mainCheckout) { + const mainCandidate = path.join(mainCheckout, '.squad'); + if (fs.existsSync(mainCandidate) && fs.statSync(mainCandidate).isDirectory()) { + return mainCandidate; + } + } return null; } @@ -103,7 +150,10 @@ const SQUAD_DIR_NAMES = ['.squad', '.ai-team'] as const; * Find the squad directory by walking up from `startDir`, checking both * `.squad/` and `.ai-team/` (legacy fallback). * - * Returns the absolute path, the directory name used, and whether it's legacy. + * Worktree-aware: when `.git` is a file (worktree pointer), falls back to + * checking the main checkout for either squad directory name. + * + * Returns the absolute path and the directory name used. */ function findSquadDir(startDir: string): { dir: string; name: '.squad' | '.ai-team' } | null { let current = path.resolve(startDir); @@ -117,9 +167,22 @@ function findSquadDir(startDir: string): { dir: string; name: '.squad' | '.ai-te } } - // Stop if we hit a .git boundary const gitMarker = path.join(current, '.git'); if (fs.existsSync(gitMarker)) { + if (fs.statSync(gitMarker).isDirectory()) { + // Real repo root — stop, no squad dir found in this checkout + return null; + } + // .git is a file — this is a git worktree; fall back to main checkout + const mainCheckout = getMainWorktreePath(current, gitMarker); + if (mainCheckout) { + for (const name of SQUAD_DIR_NAMES) { + const candidate = path.join(mainCheckout, name); + if (fs.existsSync(candidate) && fs.statSync(candidate).isDirectory()) { + return { dir: candidate, name }; + } + } + } return null; } diff --git a/test/resolution.test.ts b/test/resolution.test.ts index 8043ccccb..8e072961e 100644 --- a/test/resolution.test.ts +++ b/test/resolution.test.ts @@ -62,6 +62,37 @@ describe('resolveSquad()', () => { expect(resolveSquad(join(TMP, 'repo', 'src'))).toBe(join(TMP, 'repo', '.squad')); }); + it('falls back to main checkout .squad/ when worktree has none', () => { + // main checkout: TMP/main with .git dir + .squad dir + mkdirSync(join(TMP, 'main', '.git'), { recursive: true }); + mkdirSync(join(TMP, 'main', '.squad'), { recursive: true }); + // worktree: TMP/main/.worktrees/feature with .git FILE + mkdirSync(join(TMP, 'main', '.worktrees', 'feature', 'src'), { recursive: true }); + writeFileSync( + join(TMP, 'main', '.worktrees', 'feature', '.git'), + 'gitdir: ../../.git/worktrees/feature', + ); + // Starting from worktree src/, should find main checkout's .squad/ + expect(resolveSquad(join(TMP, 'main', '.worktrees', 'feature', 'src'))) + .toBe(join(TMP, 'main', '.squad')); + }); + + it('prefers worktree-local .squad/ over main checkout when both exist', () => { + // main checkout with .squad/ + mkdirSync(join(TMP, 'main', '.git'), { recursive: true }); + mkdirSync(join(TMP, 'main', '.squad'), { recursive: true }); + // worktree with its own .squad/ + mkdirSync(join(TMP, 'main', '.worktrees', 'feature', '.squad'), { recursive: true }); + mkdirSync(join(TMP, 'main', '.worktrees', 'feature', 'src'), { recursive: true }); + writeFileSync( + join(TMP, 'main', '.worktrees', 'feature', '.git'), + 'gitdir: ../../.git/worktrees/feature', + ); + // Worktree-local .squad/ wins + expect(resolveSquad(join(TMP, 'main', '.worktrees', 'feature', 'src'))) + .toBe(join(TMP, 'main', '.worktrees', 'feature', '.squad')); + }); + it('defaults to cwd when no argument given', () => { // Just verify it doesn't throw const result = resolveSquad(); diff --git a/test/worktree.test.ts b/test/worktree.test.ts new file mode 100644 index 000000000..fb0c51001 --- /dev/null +++ b/test/worktree.test.ts @@ -0,0 +1,216 @@ +/** + * Worktree regression tests — Issue #521 + * + * Both resolveSquad() and detectSquadDir() must handle .git FILES (worktree + * pointers) by reading the gitdir: pointer and falling back to the main + * checkout's .squad/. The implementation parses .git via fs.readFileSync — + * no child_process calls are made. + * + * Test directory structure: + * tmp/ + * main/ ← main checkout + * .git/ ← real .git directory + * worktrees/ + * feature-521/ + * .squad/ + * worktree/ ← worktree + * .git ← FILE: "gitdir: ../main/.git/worktrees/feature-521" + * + * @see packages/squad-sdk/src/resolution.ts resolveSquad() + * @see packages/squad-cli/src/cli/core/detect-squad-dir.ts detectSquadDir() + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { + mkdtempSync, + mkdirSync, + rmSync, + writeFileSync, + existsSync, +} from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; + +import { resolveSquad } from '@bradygaster/squad-sdk/resolution'; +import { detectSquadDir } from '@bradygaster/squad-cli/core/detect-squad-dir'; + +// --------------------------------------------------------------------------- +// Test suite +// --------------------------------------------------------------------------- + +describe('worktree regression (#521)', () => { + let tmp: string; + + beforeEach(() => { + tmp = mkdtempSync(join(tmpdir(), 'squad-worktree-test-')); + }); + + afterEach(() => { + if (existsSync(tmp)) { + rmSync(tmp, { recursive: true, force: true }); + } + }); + + // ── resolveSquad() ──────────────────────────────────────────────────────── + + describe('resolveSquad()', () => { + it('.git FILE is not treated as a hard stop — falls back to main checkout', () => { + // Worktree: .git is a FILE (pointer), no .squad/ + const worktree = join(tmp, 'worktree'); + mkdirSync(worktree); + writeFileSync( + join(worktree, '.git'), + 'gitdir: ../main/.git/worktrees/feature-521', + ); + + // Main checkout: .git is a DIRECTORY with worktrees/, .squad/ is present + const main = join(tmp, 'main'); + mkdirSync(join(main, '.git', 'worktrees', 'feature-521'), { recursive: true }); + mkdirSync(join(main, '.squad'), { recursive: true }); + + // CURRENT CODE → returns null (treats .git file as hard stop) ← FAILS + // AFTER FIX → returns main/.squad via worktree fallback ← PASSES + expect(resolveSquad(worktree)).toBe(join(main, '.squad')); + }); + + it('.git DIRECTORY still marks the repo root boundary correctly', () => { + // Normal checkout: .git is a directory, .squad/ is present inside + const repo = join(tmp, 'repo'); + mkdirSync(join(repo, '.git'), { recursive: true }); + mkdirSync(join(repo, '.squad'), { recursive: true }); + mkdirSync(join(repo, 'src'), { recursive: true }); + + // resolveSquad() should find .squad/ before hitting the .git directory + expect(resolveSquad(join(repo, 'src'))).toBe(join(repo, '.squad')); + }); + + it('worktree fallback: resolves .squad/ from src/ subdir inside worktree', () => { + // Worktree has a nested src/ — walk-up crosses the worktree root + const worktree = join(tmp, 'worktree'); + mkdirSync(join(worktree, 'src'), { recursive: true }); + writeFileSync( + join(worktree, '.git'), + 'gitdir: ../main/.git/worktrees/feature-521', + ); + + const main = join(tmp, 'main'); + mkdirSync(join(main, '.git', 'worktrees', 'feature-521'), { recursive: true }); + mkdirSync(join(main, '.squad'), { recursive: true }); + + // CURRENT CODE → returns null ← FAILS + // AFTER FIX → returns main/.squad ← PASSES + expect(resolveSquad(join(worktree, 'src'))).toBe(join(main, '.squad')); + }); + + it('worktree fallback: returns null when main checkout also has no .squad/', () => { + // Worktree: .git file, no .squad/ + const worktree = join(tmp, 'worktree'); + mkdirSync(worktree); + writeFileSync( + join(worktree, '.git'), + 'gitdir: ../main/.git/worktrees/feature-521', + ); + + // Main: .git directory with worktrees/, but ALSO no .squad/ + const main = join(tmp, 'main'); + mkdirSync(join(main, '.git', 'worktrees', 'feature-521'), { recursive: true }); + + // Neither location has .squad/→ should return null in both old and new code + // (This is a "should stay null" control test.) + expect(resolveSquad(worktree)).toBeNull(); + }); + }); + + // ── detectSquadDir() ────────────────────────────────────────────────────── + + describe('detectSquadDir()', () => { + it('finds .squad/ from main checkout when invoked from a worktree', () => { + // Worktree: .git file, no .squad/ + const worktree = join(tmp, 'worktree'); + mkdirSync(worktree); + writeFileSync( + join(worktree, '.git'), + 'gitdir: ../main/.git/worktrees/feature-521', + ); + + // Main: .git directory with worktrees/, .squad/ present + const main = join(tmp, 'main'); + mkdirSync(join(main, '.git', 'worktrees', 'feature-521'), { recursive: true }); + mkdirSync(join(main, '.squad'), { recursive: true }); + + // CURRENT CODE → returns { path: worktree/.squad, ... } — non-existent ← FAILS + // AFTER FIX → returns { path: main/.squad, ... } ← PASSES + const info = detectSquadDir(worktree); + expect(info.path).toBe(join(main, '.squad')); + expect(existsSync(info.path)).toBe(true); + expect(info.isLegacy).toBe(false); + }); + + it('local checkout (non-worktree): still finds .squad/ at dest', () => { + // Normal checkout — no worktree involved + const repo = join(tmp, 'repo'); + mkdirSync(join(repo, '.git'), { recursive: true }); + mkdirSync(join(repo, '.squad'), { recursive: true }); + + const info = detectSquadDir(repo); + expect(info.path).toBe(join(repo, '.squad')); + expect(info.isLegacy).toBe(false); + }); + + it('squad init in worktree: does not silently create a duplicate .squad/', () => { + // Scenario: developer runs `squad init` from inside a worktree where + // the main checkout already has .squad/. The init command calls + // detectSquadDir(cwd) to decide where to write. + // + // CURRENT: detectSquadDir returns worktree/.squad (non-existent) → init + // scaffolds a NEW .squad/ inside the worktree — silent data split. + // + // AFTER FIX: detectSquadDir returns main/.squad → init sees an existing + // .squad/ and prompts the user instead of silently duplicating. + + const worktree = join(tmp, 'worktree'); + mkdirSync(worktree); + writeFileSync( + join(worktree, '.git'), + 'gitdir: ../main/.git/worktrees/feature-521', + ); + + const main = join(tmp, 'main'); + mkdirSync(join(main, '.git', 'worktrees', 'feature-521'), { recursive: true }); + mkdirSync(join(main, '.squad'), { recursive: true }); + + const info = detectSquadDir(worktree); + + // CURRENT CODE → info.path === worktree/.squad (wrong) ← FAILS + // AFTER FIX → info.path === main/.squad (correct) ← PASSES + expect(info.path).not.toBe(join(worktree, '.squad')); + expect(info.path).toBe(join(main, '.squad')); + + // The worktree directory must NOT have a .squad/ created as a side effect + expect(existsSync(join(worktree, '.squad'))).toBe(false); + }); + }); + + // ── statSync guard ──────────────────────────────────────────────────────── + + describe('statSync guard — crafted .git redirection', () => { + it('resolveSquad(): crafted .git pointing to non-existent path returns null, not crash', () => { + const worktree = join(tmp, 'worktree'); + mkdirSync(worktree); + // gitdir points to a path where mainCheckout/.git does not exist + writeFileSync(join(worktree, '.git'), 'gitdir: ../nonexistent/.git/worktrees/malicious'); + + expect(resolveSquad(worktree)).toBeNull(); + }); + + it('detectSquadDir(): crafted .git pointing to non-existent path returns fallback, not crash', () => { + const worktree = join(tmp, 'worktree'); + mkdirSync(worktree); + writeFileSync(join(worktree, '.git'), 'gitdir: ../nonexistent/.git/worktrees/malicious'); + + const info = detectSquadDir(worktree); + // Falls back to the default (worktree/.squad) without crashing + expect(info.path).toBe(join(worktree, '.squad')); + }); + }); +});