diff --git a/.changeset/monorepo-subfolder-support.md b/.changeset/monorepo-subfolder-support.md new file mode 100644 index 000000000..83eefed28 --- /dev/null +++ b/.changeset/monorepo-subfolder-support.md @@ -0,0 +1,8 @@ +--- +"@bradygaster/squad-cli": patch +"@bradygaster/squad-sdk": patch +--- + +fix: monorepo subfolder support — never run git init (#939) + +When `squad init` runs in a subfolder of a monorepo, place `.github/agents/squad.agent.md` at the git root and `.squad/` in the subfolder. Never run `git init`. diff --git a/.github/agents/squad.agent.md b/.github/agents/squad.agent.md index c1d0a34e7..01e18dfad 100644 --- a/.github/agents/squad.agent.md +++ b/.github/agents/squad.agent.md @@ -625,15 +625,17 @@ Squad and all spawned agents may be running inside a **git worktree** rather tha **How the Coordinator resolves the team root (on every session start):** -1. Run `git rev-parse --show-toplevel` to get the current worktree root. -2. Check if `.squad/` exists at that root (fall back to `.ai-team/` for repos that haven't migrated yet). +1. **Check CWD first** — does `.squad/` exist in the current working directory? + - **Yes** → Team root = CWD. This handles monorepos where `.squad/` lives in a subfolder. +2. If not, run `git rev-parse --show-toplevel` to get the current worktree root. +3. Check if `.squad/` exists at that root (fall back to `.ai-team/` for repos that haven't migrated yet). - **Yes** → use **worktree-local** strategy. Team root = current worktree root. - **No** → use **main-checkout** strategy. Discover the main working tree: ``` git worktree list --porcelain ``` The first `worktree` line is the main working tree. Team root = that path. -3. The user may override the strategy at any time (e.g., *"use main checkout for team state"* or *"keep team state in this worktree"*). +4. The user may override the strategy at any time (e.g., *"use main checkout for team state"* or *"keep team state in this worktree"*). **Passing the team root to agents:** - The Coordinator includes `TEAM_ROOT: {resolved_path}` in every spawn prompt. diff --git a/.squad-templates/squad.agent.md b/.squad-templates/squad.agent.md index c1d0a34e7..01e18dfad 100644 --- a/.squad-templates/squad.agent.md +++ b/.squad-templates/squad.agent.md @@ -625,15 +625,17 @@ Squad and all spawned agents may be running inside a **git worktree** rather tha **How the Coordinator resolves the team root (on every session start):** -1. Run `git rev-parse --show-toplevel` to get the current worktree root. -2. Check if `.squad/` exists at that root (fall back to `.ai-team/` for repos that haven't migrated yet). +1. **Check CWD first** — does `.squad/` exist in the current working directory? + - **Yes** → Team root = CWD. This handles monorepos where `.squad/` lives in a subfolder. +2. If not, run `git rev-parse --show-toplevel` to get the current worktree root. +3. Check if `.squad/` exists at that root (fall back to `.ai-team/` for repos that haven't migrated yet). - **Yes** → use **worktree-local** strategy. Team root = current worktree root. - **No** → use **main-checkout** strategy. Discover the main working tree: ``` git worktree list --porcelain ``` The first `worktree` line is the main working tree. Team root = that path. -3. The user may override the strategy at any time (e.g., *"use main checkout for team state"* or *"keep team state in this worktree"*). +4. The user may override the strategy at any time (e.g., *"use main checkout for team state"* or *"keep team state in this worktree"*). **Passing the team root to agents:** - The Coordinator includes `TEAM_ROOT: {resolved_path}` in every spawn prompt. diff --git a/packages/squad-cli/src/cli/core/init.ts b/packages/squad-cli/src/cli/core/init.ts index c0619dd21..abaefb19a 100644 --- a/packages/squad-cli/src/cli/core/init.ts +++ b/packages/squad-cli/src/cli/core/init.ts @@ -123,30 +123,25 @@ export async function runInit(dest: string, options: RunInitOptions = {}): Promi // Detect project type const projectType = detectProjectType(dest); - // ── Parent git repo detection ───────────────────────────────────── + // ── Monorepo / subfolder detection ─────────────────────────────── // Copilot resolves .github/agents/ relative to the git root. - // If CWD is inside a parent git repo, the agent file will be - // invisible to copilot because the git root points elsewhere. - try { - const gitRoot = execFileSync('git', ['rev-parse', '--show-toplevel'], { - cwd: dest, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'], - }).trim().replace(/\//g, path.sep); - const normalDest = path.resolve(dest); - const normalGitRoot = path.resolve(gitRoot); - if (normalDest.toLowerCase() !== normalGitRoot.toLowerCase()) { - console.log(); - console.log(`${YELLOW}${BOLD}⚠ Parent git repo detected${RESET}`); - console.log(`${YELLOW} Git root: ${normalGitRoot}${RESET}`); - console.log(`${YELLOW} You're in: ${normalDest}${RESET}`); - console.log(); - console.log(`${DIM}Copilot resolves .github/agents/ from the git root, not from here.${RESET}`); - console.log(`${DIM}The Squad agent won't be visible to copilot in this folder.${RESET}`); - console.log(`${DIM}Consider running squad init from the git root instead.${RESET}`); - console.log(); - } - } catch { - // No git available or not in a git repo — that's fine, continue normally. - // Copilot will fall back to CWD for .github/agents/ discovery. + // If CWD is a subfolder of a larger repo (monorepo), we place + // squad.agent.md at the git root and .squad/ in the subfolder. + // Never run `git init` — it creates broken nested repos (#939). + let agentFileRoot = dest; // default: place agent file relative to dest + const parentGitRoot = detectParentGitRepo(dest); + if (parentGitRoot) { + console.log(); + console.log(`${CYAN}${BOLD}📦 Monorepo detected${RESET}`); + console.log(`${DIM} Git root: ${parentGitRoot}${RESET}`); + console.log(`${DIM} You're in: ${path.resolve(dest)}${RESET}`); + console.log(); + console.log(`${DIM}squad.agent.md → git root (.github/agents/)${RESET}`); + console.log(`${DIM}.squad/ → here (${path.basename(path.resolve(dest))}/)${RESET}`); + console.log(); + // Place the agent file at the git root so Copilot can find it. + // Team state (.squad/) stays in cwd — resolved via cwd at runtime. + agentFileRoot = parentGitRoot; } // Detect squad directory @@ -205,6 +200,7 @@ export async function runInit(dest: string, options: RunInitOptions = {}): Promi // Build SDK options const initOptions: InitOptions = { teamRoot: dest, + agentFileRoot, projectName: path.basename(dest) || 'my-project', agents: [ { @@ -255,7 +251,7 @@ export async function runInit(dest: string, options: RunInitOptions = {}): Promi process.off('SIGINT', sigintHandler); // Ensure version is fully stamped in squad.agent.md - const agentPath = path.join(dest, '.github', 'agents', 'squad.agent.md'); + const agentPath = path.join(agentFileRoot, '.github', 'agents', 'squad.agent.md'); if (storage.existsSync(agentPath)) { stampVersion(agentPath, version); } diff --git a/packages/squad-cli/templates/squad.agent.md.template b/packages/squad-cli/templates/squad.agent.md.template index c1d0a34e7..01e18dfad 100644 --- a/packages/squad-cli/templates/squad.agent.md.template +++ b/packages/squad-cli/templates/squad.agent.md.template @@ -625,15 +625,17 @@ Squad and all spawned agents may be running inside a **git worktree** rather tha **How the Coordinator resolves the team root (on every session start):** -1. Run `git rev-parse --show-toplevel` to get the current worktree root. -2. Check if `.squad/` exists at that root (fall back to `.ai-team/` for repos that haven't migrated yet). +1. **Check CWD first** — does `.squad/` exist in the current working directory? + - **Yes** → Team root = CWD. This handles monorepos where `.squad/` lives in a subfolder. +2. If not, run `git rev-parse --show-toplevel` to get the current worktree root. +3. Check if `.squad/` exists at that root (fall back to `.ai-team/` for repos that haven't migrated yet). - **Yes** → use **worktree-local** strategy. Team root = current worktree root. - **No** → use **main-checkout** strategy. Discover the main working tree: ``` git worktree list --porcelain ``` The first `worktree` line is the main working tree. Team root = that path. -3. The user may override the strategy at any time (e.g., *"use main checkout for team state"* or *"keep team state in this worktree"*). +4. The user may override the strategy at any time (e.g., *"use main checkout for team state"* or *"keep team state in this worktree"*). **Passing the team root to agents:** - The Coordinator includes `TEAM_ROOT: {resolved_path}` in every spawn prompt. diff --git a/packages/squad-sdk/src/config/init.ts b/packages/squad-sdk/src/config/init.ts index 663fb74c1..1533a3421 100644 --- a/packages/squad-sdk/src/config/init.ts +++ b/packages/squad-sdk/src/config/init.ts @@ -8,7 +8,7 @@ * @module config/init */ -import { join, dirname } from 'path'; +import { join, dirname, relative as pathRelative, resolve as pathResolve } from 'path'; import { fileURLToPath } from 'url'; import type { StorageProvider } from '../storage/index.js'; import { FSStorageProvider } from '../storage/index.js'; @@ -136,6 +136,11 @@ export interface InitOptions { streams?: SubSquadDefinition[]; /** If true, use built-in base roles with useRole() in SDK config (default: false) */ roles?: boolean; + /** Root directory for the .github/agents/squad.agent.md file. + * Defaults to teamRoot. In monorepos, this should be the git root + * so Copilot can discover the agent file, while teamRoot stays in + * the subfolder where .squad/ lives. */ + agentFileRoot?: string; /** ADO work item configuration — used when platform is azure-devops */ adoConfig?: { defaultWorkItemType?: string; @@ -669,15 +674,10 @@ export async function initSquad(options: InitOptions, storage: StorageProvider = // Helper to convert absolute path to relative const toRelativePath = (absolutePath: string): string => { - // Use path separator-agnostic approach - if (absolutePath.startsWith(teamRoot)) { - const relative = absolutePath.slice(teamRoot.length); - // Remove leading separator if present - return relative.startsWith('/') || relative.startsWith('\\') - ? relative.slice(1) - : relative; - } - return absolutePath; + // Use path.relative for correct cross-root handling (monorepo: agentFileRoot != teamRoot) + const rel = pathRelative(teamRoot, absolutePath); + // path.relative returns '' for same path, use '.' instead + return rel || '.'; }; // Helper to write file (respects skipExisting) @@ -1062,7 +1062,7 @@ ${projectDescription ? `- **Description:** ${projectDescription}\n` : ''}- **Cre // Create .github/agents/squad.agent.md // ------------------------------------------------------------------------- - const agentFile = join(teamRoot, '.github', 'agents', 'squad.agent.md'); + const agentFile = join(options.agentFileRoot ?? teamRoot, '.github', 'agents', 'squad.agent.md'); if (!storage.existsSync(agentFile) || !skipExisting) { if (templatesDir && storage.existsSync(join(templatesDir, 'squad.agent.md.template'))) { let agentContent = storage.readSync(join(templatesDir, 'squad.agent.md.template')) ?? ''; @@ -1110,6 +1110,15 @@ ${projectDescription ? `- **Description:** ${projectDescription}\n` : ''}- **Cre // ------------------------------------------------------------------------- if (includeWorkflows && isGitHub && templatesDir && storage.existsSync(join(templatesDir, 'workflows'))) { + // In monorepo mode (agentFileRoot != teamRoot), skip workflow placement. + // GitHub Actions only reads from /.github/workflows/ — placing + // workflows in a subfolder has no effect. Multiple squads in one monorepo + // would also conflict on the same workflow files. (#939) + const isMonorepoSubfolder = !!options.agentFileRoot && + pathResolve(options.agentFileRoot).toLowerCase() !== pathResolve(teamRoot).toLowerCase(); + if (isMonorepoSubfolder) { + warnings.push('Skipped GitHub Actions workflows in monorepo-subfolder mode — workflows must be at the git root. Set up workflows manually or use a single shared workflow for all squads.'); + } else { const workflowsSrc = join(templatesDir, 'workflows'); const workflowsDest = join(teamRoot, '.github', 'workflows'); @@ -1128,6 +1137,7 @@ ${projectDescription ? `- **Description:** ${projectDescription}\n` : ''}- **Cre } } } + } } // ------------------------------------------------------------------------- diff --git a/packages/squad-sdk/templates/squad.agent.md.template b/packages/squad-sdk/templates/squad.agent.md.template index c1d0a34e7..01e18dfad 100644 --- a/packages/squad-sdk/templates/squad.agent.md.template +++ b/packages/squad-sdk/templates/squad.agent.md.template @@ -625,15 +625,17 @@ Squad and all spawned agents may be running inside a **git worktree** rather tha **How the Coordinator resolves the team root (on every session start):** -1. Run `git rev-parse --show-toplevel` to get the current worktree root. -2. Check if `.squad/` exists at that root (fall back to `.ai-team/` for repos that haven't migrated yet). +1. **Check CWD first** — does `.squad/` exist in the current working directory? + - **Yes** → Team root = CWD. This handles monorepos where `.squad/` lives in a subfolder. +2. If not, run `git rev-parse --show-toplevel` to get the current worktree root. +3. Check if `.squad/` exists at that root (fall back to `.ai-team/` for repos that haven't migrated yet). - **Yes** → use **worktree-local** strategy. Team root = current worktree root. - **No** → use **main-checkout** strategy. Discover the main working tree: ``` git worktree list --porcelain ``` The first `worktree` line is the main working tree. Team root = that path. -3. The user may override the strategy at any time (e.g., *"use main checkout for team state"* or *"keep team state in this worktree"*). +4. The user may override the strategy at any time (e.g., *"use main checkout for team state"* or *"keep team state in this worktree"*). **Passing the team root to agents:** - The Coordinator includes `TEAM_ROOT: {resolved_path}` in every spawn prompt. diff --git a/templates/squad.agent.md.template b/templates/squad.agent.md.template index c1d0a34e7..01e18dfad 100644 --- a/templates/squad.agent.md.template +++ b/templates/squad.agent.md.template @@ -625,15 +625,17 @@ Squad and all spawned agents may be running inside a **git worktree** rather tha **How the Coordinator resolves the team root (on every session start):** -1. Run `git rev-parse --show-toplevel` to get the current worktree root. -2. Check if `.squad/` exists at that root (fall back to `.ai-team/` for repos that haven't migrated yet). +1. **Check CWD first** — does `.squad/` exist in the current working directory? + - **Yes** → Team root = CWD. This handles monorepos where `.squad/` lives in a subfolder. +2. If not, run `git rev-parse --show-toplevel` to get the current worktree root. +3. Check if `.squad/` exists at that root (fall back to `.ai-team/` for repos that haven't migrated yet). - **Yes** → use **worktree-local** strategy. Team root = current worktree root. - **No** → use **main-checkout** strategy. Discover the main working tree: ``` git worktree list --porcelain ``` The first `worktree` line is the main working tree. Team root = that path. -3. The user may override the strategy at any time (e.g., *"use main checkout for team state"* or *"keep team state in this worktree"*). +4. The user may override the strategy at any time (e.g., *"use main checkout for team state"* or *"keep team state in this worktree"*). **Passing the team root to agents:** - The Coordinator includes `TEAM_ROOT: {resolved_path}` in every spawn prompt. diff --git a/test/init-scaffolding.test.ts b/test/init-scaffolding.test.ts index c73411488..3b180a812 100644 --- a/test/init-scaffolding.test.ts +++ b/test/init-scaffolding.test.ts @@ -218,11 +218,61 @@ describe('no-remote resilience (#579)', () => { expect(existsSync(join(TEST_ROOT, '.github', 'agents', 'squad.agent.md'))).toBe(true); }); + it('runInit CLI: monorepo subfolder — no nested .git, agent at git root (#939)', async () => { + // Set up a monorepo: git init at TEST_ROOT, run from a subfolder + gitInit(TEST_ROOT); + const subfolder = join(TEST_ROOT, 'services', 'api'); + await mkdir(subfolder, { recursive: true }); + + // runInit from the subfolder — CLI should detect the parent git repo + await expect(runInit(subfolder)).resolves.toBeUndefined(); + + // 1. No nested .git/ in subfolder (the original bug) + expect(existsSync(join(subfolder, '.git'))).toBe(false); + // 2. .squad/ created in the subfolder + expect(existsSync(join(subfolder, '.squad'))).toBe(true); + expect(existsSync(join(subfolder, '.squad', 'casting', 'registry.json'))).toBe(true); + // 3. squad.agent.md placed at the git root, not the subfolder + expect(existsSync(join(TEST_ROOT, '.github', 'agents', 'squad.agent.md'))).toBe(true); + expect(existsSync(join(subfolder, '.github', 'agents', 'squad.agent.md'))).toBe(false); + }); + it('initSquad succeeds when git is not initialized at all', async () => { // TEST_ROOT is a plain directory — no git init await expect(initSquad(sdkOptions(TEST_ROOT))).resolves.toBeDefined(); expect(existsSync(join(TEST_ROOT, '.squad', 'casting', 'registry.json'))).toBe(true); }); + + it('monorepo subfolder: no nested git init, agent at root, .squad in subfolder (#939)', async () => { + // Set up a monorepo with a subfolder + gitInit(TEST_ROOT); + const subfolder = join(TEST_ROOT, 'team-alpha'); + await mkdir(subfolder, { recursive: true }); + + // Run SDK init with agentFileRoot pointing to the git root + // Enable workflows to test monorepo skip behavior + const result = await initSquad({ + ...sdkOptions(subfolder), + agentFileRoot: TEST_ROOT, + includeWorkflows: true, + }); + + // 1. No nested .git/ in subfolder + expect(existsSync(join(subfolder, '.git'))).toBe(false); + // 2. .squad/ created in subfolder + expect(existsSync(join(subfolder, '.squad'))).toBe(true); + expect(existsSync(join(subfolder, '.squad', 'casting', 'registry.json'))).toBe(true); + // 3. squad.agent.md created at monorepo root + expect(existsSync(join(TEST_ROOT, '.github', 'agents', 'squad.agent.md'))).toBe(true); + // 4. squad.agent.md NOT in subfolder + expect(existsSync(join(subfolder, '.github', 'agents', 'squad.agent.md'))).toBe(false); + // 5. createdFiles should include relative path with .. + expect(result.createdFiles.some(f => f.includes('squad.agent.md'))).toBe(true); + // 6. Workflows NOT placed in subfolder (GitHub Actions ignores them there) + expect(existsSync(join(subfolder, '.github', 'workflows'))).toBe(false); + // 7. Warning emitted about skipped workflows + expect(result.warnings?.some(w => w.includes('monorepo-subfolder'))).toBe(true); + }); }); // ─── Doctor validation after init ──────────────────────────────────────