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
4 changes: 4 additions & 0 deletions packages/squad-cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
49 changes: 48 additions & 1 deletion packages/squad-cli/src/cli/core/detect-squad-dir.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: <path-to-.git/worktrees/name>`
*/
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 };
}
47 changes: 46 additions & 1 deletion packages/squad-cli/src/cli/core/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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();
Expand Down
75 changes: 69 additions & 6 deletions packages/squad-sdk/src/resolution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: <relative-or-absolute-path-to-.git/worktrees/name>`
* 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`.
Expand All @@ -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;
}

Expand All @@ -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);
Expand All @@ -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;
}

Expand Down
31 changes: 31 additions & 0 deletions test/resolution.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
Loading
Loading