Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .changeset/monorepo-subfolder-support.md
Original file line number Diff line number Diff line change
@@ -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`.
8 changes: 5 additions & 3 deletions .github/agents/squad.agent.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
8 changes: 5 additions & 3 deletions .squad-templates/squad.agent.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
44 changes: 20 additions & 24 deletions packages/squad-cli/src/cli/core/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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: [
Comment thread
tamirdresher marked this conversation as resolved.
Resolved
{
Expand Down Expand Up @@ -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);
}
Expand Down
8 changes: 5 additions & 3 deletions packages/squad-cli/templates/squad.agent.md.template
Original file line number Diff line number Diff line change
Expand Up @@ -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"*).
Comment thread
tamirdresher marked this conversation as resolved.
Resolved

**Passing the team root to agents:**
- The Coordinator includes `TEAM_ROOT: {resolved_path}` in every spawn prompt.
Expand Down
32 changes: 21 additions & 11 deletions packages/squad-sdk/src/config/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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) {
Comment thread
tamirdresher marked this conversation as resolved.
Resolved
if (templatesDir && storage.existsSync(join(templatesDir, 'squad.agent.md.template'))) {
let agentContent = storage.readSync(join(templatesDir, 'squad.agent.md.template')) ?? '';
Expand Down Expand Up @@ -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 <repo-root>/.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 {
Comment thread
tamirdresher marked this conversation as resolved.
Resolved
const workflowsSrc = join(templatesDir, 'workflows');
const workflowsDest = join(teamRoot, '.github', 'workflows');

Expand All @@ -1128,6 +1137,7 @@ ${projectDescription ? `- **Description:** ${projectDescription}\n` : ''}- **Cre
}
}
}
}
}

// -------------------------------------------------------------------------
Expand Down
8 changes: 5 additions & 3 deletions packages/squad-sdk/templates/squad.agent.md.template
Original file line number Diff line number Diff line change
Expand Up @@ -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"*).
Comment thread
tamirdresher marked this conversation as resolved.
Resolved

**Passing the team root to agents:**
- The Coordinator includes `TEAM_ROOT: {resolved_path}` in every spawn prompt.
Expand Down
8 changes: 5 additions & 3 deletions templates/squad.agent.md.template
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
50 changes: 50 additions & 0 deletions test/init-scaffolding.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
Comment thread
tamirdresher marked this conversation as resolved.
Resolved
});

// ─── Doctor validation after init ──────────────────────────────────────
Expand Down