diff --git a/.changeset/linked-team-git-backend.md b/.changeset/linked-team-git-backend.md new file mode 100644 index 000000000..fc816419f --- /dev/null +++ b/.changeset/linked-team-git-backend.md @@ -0,0 +1,5 @@ +--- +"@bradygaster/squad-sdk": patch +--- + +A linked project (`teamRoot` in `.squad/config.json`) on the `orphan` or `two-layer` state backend can use `squad_state` again. `resolveSquadState` ran the git backend in the project repo and rooted its storage adapter at the project's `.squad`, while every tool builds paths under the team's `.squad`, so each `memory.*`, `squad_state_write` and `squad_state_read` call failed with "path is outside squadDir". The team repo owns the state, so the git backend now runs in the team repo (on its `squad-state` branch) and the adapter is rooted at the team's `.squad`, the same root the local backend already used. The team repo's git top level must be exactly the team repo (compared after resolving links, drive letters and case), so a team dir that only sits inside another repository, or a `.squad` linked out of the team repo, never gets state written into that other repository. In those cases, and when the team root is not in a git repository, resolution fails with a message that says so, instead of failing on every call. diff --git a/docs/src/content/docs/features/state-backends.md b/docs/src/content/docs/features/state-backends.md index d44b11290..123c072ff 100644 --- a/docs/src/content/docs/features/state-backends.md +++ b/docs/src/content/docs/features/state-backends.md @@ -97,6 +97,8 @@ If a non-default backend fails to initialize (e.g., Git is not available, permis ``` Warning: State backend 'two-layer' failed: . Falling back to 'local'. ``` +### Linked Projects +A project linked to a team repo (`teamRoot` in its `.squad/config.json`, which `squad link` writes) keeps its state with the team. With `orphan` or `two-layer`, that is the team repo's `squad-state` branch, not the project's. The backend type is still read from the project's `.squad/config.json`. The team repo must be a Git repository of its own: its Git root has to be the team repo itself (a worktree counts), after symlinks and junctions are resolved. If the team root isn't in a Git repository, only sits inside another repository, or its `.squad/` is a link out of the team repo, Squad stops with an error naming the team root instead of falling back to `local` or writing state into another repository. --- ## Comparison diff --git a/packages/squad-sdk/src/resolution.ts b/packages/squad-sdk/src/resolution.ts index 0e3e5f16d..64b2b0139 100644 --- a/packages/squad-sdk/src/resolution.ts +++ b/packages/squad-sdk/src/resolution.ts @@ -18,7 +18,7 @@ import os from 'node:os'; import crypto from 'node:crypto'; import { execFileSync } from 'node:child_process'; import { FSStorageProvider } from './storage/fs-storage-provider.js'; -import { resolveStateBackend, StateBackendStorageAdapter, type StateBackend, type StateBackendType } from './state-backend.js'; +import { configuredStateBackend, resolveStateBackend, StateBackendStorageAdapter, type StateBackend, type StateBackendType } from './state-backend.js'; import type { StorageProvider } from './storage/storage-provider.js'; const storage = new FSStorageProvider(); @@ -928,7 +928,11 @@ export interface SquadStateContext { paths: ResolvedSquadPaths; /** The active state backend (local, git-notes, or orphan) */ backend: StateBackend; - /** The repo root directory (for git-native backends) */ + /** + * The project's repo root directory (worktree-aware). For a linked team on a + * git backend, the state branch lives in the team repo instead; the backend + * is already bound to it, so callers keep using this for project paths. + */ repoRoot: string; /** StorageProvider backed by the active state backend — pass to SDK modules */ storage: StorageProvider; @@ -961,18 +965,102 @@ export function resolveSquadState(startDir?: string, cliOverride?: StateBackendT repoRoot = path.resolve(paths.projectDir, '..'); } + // A linked team owns its state (#2320): a git backend keeps it on the team + // repo's state branch, not the project's. Fail closed when that repo cannot + // be established exactly, instead of falling back to 'local' or to whatever + // repository happens to contain the team dir. Only the backend moves: + // repoRoot stays the project's repository, where hooks and agent turns work. + let stateRepoRoot = repoRoot; + if (paths.mode === 'remote') { + const { type } = configuredStateBackend(paths.projectDir, cliOverride); + if (type === 'orphan' || type === 'two-layer') { + stateRepoRoot = linkedTeamRepoRoot(paths, type); + } + } + // Resolve the backend from config + CLI override - const backend = resolveStateBackend(paths.projectDir, repoRoot, cliOverride); + const backend = resolveStateBackend(paths.projectDir, stateRepoRoot, cliOverride); // For local backend, use FSStorageProvider directly (more capable). // For git-notes/orphan, bridge via StateBackendStorageAdapter. - // rootDir is paths.teamSquadDir (matches the squadRoot every local-backend + // Both are rooted at paths.teamSquadDir (matches the squadRoot every // caller — e.g. ToolRegistry in state-mcp.ts — builds its paths against), // so the traversal guard actually validates instead of no-op'ing on an // unset rootDir and letting a bad upstream path resolve silently. const stateStorage: StorageProvider = backend.name === 'local' ? new FSStorageProvider(paths.teamSquadDir) - : new StateBackendStorageAdapter(backend, paths.projectDir); + : new StateBackendStorageAdapter(backend, paths.teamSquadDir); return { paths, backend, repoRoot, storage: stateStorage }; } + +/** + * The repository a linked team's git state backend writes to (#2320). + * + * The team repo is the directory that holds the team's squad dir: `teamRoot` itself when + * it contains `.squad/` (what `squad link` writes), the parent when `teamRoot` names the + * `.squad/` dir, or `teamRoot` when the squad files sit at its root. Its git top level must + * be exactly that directory (a linked worktree's root counts). Containing it is not enough: + * a team dir with no repository of its own that sits inside another one, whether reached + * through `..`, an absolute `teamRoot` or a symlink/junction, is refused, and so is a team + * `.squad` that is a link out of the team dir. Paths are compared canonically (realpath, + * so links, 8.3 names and drive-letter/case differences on Windows don't matter). + */ +function linkedTeamRepoRoot(paths: ResolvedSquadPaths, type: StateBackendType): string { + const prefix = `The linked team at ${paths.teamDir} uses the '${type}' state backend, which keeps state on a branch of the team repo`; + const fix = `or set "stateBackend" to "local" in ${path.join(paths.projectDir, 'config.json')}.`; + const teamDir = canonicalDir(paths.teamDir); + const squadDir = canonicalDir(paths.teamSquadDir); + if (teamDir === null || squadDir === null || !storage.existsSync(teamDir) || !storage.isDirectorySync(teamDir)) { + throw new Error(`${prefix}, but the team root does not exist or cannot be resolved. Check "teamRoot", ${fix}`); + } + + // The team repo, and the squad dir it should contain. A link may move the team as a whole + // (teamRoot itself), but must not take the squad dir out of the team repo. + let teamRepo: string; + let expectedParent: string | null; + if (paths.teamSquadDir !== paths.teamDir) { + teamRepo = teamDir; // teamRoot is the team repo, with the squad dir nested in it + expectedParent = path.dirname(squadDir); + } else if (isSquadDirName(path.basename(paths.teamDir)) || isSquadDirName(path.basename(teamDir))) { + expectedParent = canonicalDir(path.dirname(paths.teamDir)); // teamRoot names the squad dir itself + teamRepo = path.dirname(teamDir); + } else { + teamRepo = teamDir; // squad files at the root of teamRoot + expectedParent = teamDir; + } + if (expectedParent === null || !samePath(expectedParent, teamRepo)) { + throw new Error(`${prefix}, but the team's squad dir resolves to ${squadDir}, outside the team repo it was configured in. ` + + `Squad will not write the team's state into another location. Replace the link with a real directory, ${fix}`); + } + + const topLevel = gitTopLevel(teamRepo); + if (topLevel === null) { + throw new Error(`${prefix}, but the team root is not in a git repository. Initialize git in the team repo, ${fix}`); + } + const canonicalTop = canonicalDir(topLevel); + if (canonicalTop === null || !samePath(canonicalTop, teamRepo)) { + throw new Error(`${prefix}, but the team repo ${teamRepo} is not the root of a git repository: it is inside the ` + + `repository at ${canonicalTop ?? topLevel}. Squad will not write the team's state into another repository. ` + + `Initialize git in the team repo, ${fix}`); + } + return topLevel; // git's own spelling, as the backend receives for an unlinked project +} + +/** `.squad` or `.ai-team`; case-insensitive on Windows, where `C:\team\.SQUAD` names the same directory. */ +function isSquadDirName(name: string): boolean { + const n = process.platform === 'win32' ? name.toLowerCase() : name; + return n === '.squad' || n === '.ai-team'; +} + +/** `git rev-parse --show-toplevel` from `dir`, or null when it is not in a repository. */ +function gitTopLevel(dir: string): string | null { + if (!storage.existsSync(dir) || !storage.isDirectorySync(dir)) return null; + try { + return execFileSync('git', ['rev-parse', '--show-toplevel'], { + cwd: dir, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'], + }).trim() || null; + } catch { + return null; + } +} diff --git a/packages/squad-sdk/src/state-backend.ts b/packages/squad-sdk/src/state-backend.ts index 228c4bc48..b7e7adb73 100644 --- a/packages/squad-sdk/src/state-backend.ts +++ b/packages/squad-sdk/src/state-backend.ts @@ -1486,7 +1486,14 @@ export class TwoLayerBackend implements StateBackend { export interface StateBackendConfig { stateBackend?: StateBackendType; } -export function resolveStateBackend(squadDir: string, repoRoot: string, cliOverride?: StateBackendType): StateBackend { +/** + * The backend a squad dir asks for: the CLI override, then `config.json`, then 'local'. + * `explicit` is false only when neither names one. + */ +export function configuredStateBackend( + squadDir: string, + cliOverride?: StateBackendType, +): { type: StateBackendType; explicit: boolean } { let configBackend: StateBackendType | undefined; try { const configPath = path.join(squadDir, 'config.json'); @@ -1501,8 +1508,14 @@ export function resolveStateBackend(squadDir: string, repoRoot: string, cliOverr const msg = err instanceof Error ? err.message : String(err); console.warn(`⚠️ Failed to read state backend config from ${path.join(squadDir, 'config.json')}: ${msg}`); } - const explicitBackend = cliOverride !== undefined || configBackend !== undefined; - const chosen = normalizeBackendType(cliOverride ?? configBackend ?? 'local'); + return { + type: normalizeBackendType(cliOverride ?? configBackend ?? 'local'), + explicit: cliOverride !== undefined || configBackend !== undefined, + }; +} + +export function resolveStateBackend(squadDir: string, repoRoot: string, cliOverride?: StateBackendType): StateBackend { + const { type: chosen, explicit: explicitBackend } = configuredStateBackend(squadDir, cliOverride); try { return createBackend(chosen, squadDir, repoRoot); } catch (err) { diff --git a/scripts/release/test-reconciliation.mjs b/scripts/release/test-reconciliation.mjs index e20833568..a21c69a89 100644 --- a/scripts/release/test-reconciliation.mjs +++ b/scripts/release/test-reconciliation.mjs @@ -65,6 +65,7 @@ export const CATEGORY_RULES = Object.freeze([ runsOn: 'non-win32', reason: "it.runIf(process.platform !== 'win32'): POSIX-only legacy symlink-hash reconciliation", }, + { category: 'env-gated', file: /linked-team-state-boundary\.test\.ts$/, title: /^\[platform: win32\] a teamRoot naming /, reason: 'it.runIf(win32): Windows-only drive-letter and case-insensitive team root resolution' }, { category: 'env-gated', file: /profile-boundary\.test\.ts$/, reason: 'host lacks 8.3 short names or symlink/junction privilege' }, { category: 'env-gated', file: /state-backend\.test\.ts$/, reason: 'platform-gated state backend case' }, { category: 'env-gated', file: /docs-build\.test\.ts$/, reason: 'docs dependencies not installed' }, diff --git a/test/cli/hook-linked-project.test.ts b/test/cli/hook-linked-project.test.ts new file mode 100644 index 000000000..63d5895e3 --- /dev/null +++ b/test/cli/hook-linked-project.test.ts @@ -0,0 +1,87 @@ +/** + * `squad hook claude PreToolUse` in a linked project on a git state backend (#2320). + * + * The team repo holds the state branch, but the edits happen in the project repo, so the + * hook must keep checking reviewer lockouts against project paths. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { execFileSync } from 'node:child_process'; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { Readable } from 'node:stream'; +import { clearResolveSquadCache } from '@bradygaster/squad-sdk'; +import { emptyLockoutStore, rejectRevision, writeLockouts } from '../../packages/squad-sdk/src/client/adapters/lockout.js'; +import { runHookCommand } from '../../packages/squad-cli/src/cli/commands/hook.js'; + +let root: string; + +const git = (cwd: string, args: string[]) => + execFileSync('git', ['-c', 'user.name=Test', '-c', 'user.email=test', ...args], { + cwd, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'], + }).trim(); + +function initRepo(dir: string): void { + writeFileSync(join(dir, 'README.md'), '# repo\n'); + git(dir, ['init']); + git(dir, ['add', '-A']); + git(dir, ['commit', '-m', 'init']); +} + +/** Run the hook command with `input` on stdin and return what it printed. */ +async function runHook(event: string, input: Record): Promise { + const stdin = Readable.from([Buffer.from(JSON.stringify(input))]); + vi.spyOn(process, 'stdin', 'get').mockReturnValue(stdin as unknown as typeof process.stdin); + const printed: string[] = []; + vi.spyOn(process.stdout, 'write').mockImplementation((chunk: string | Uint8Array) => { + printed.push(String(chunk)); + return true; + }); + await runHookCommand(['claude', event]); + return printed.join(''); +} + +beforeEach(() => { + // Outside this checkout: anywhere under it, `git rev-parse` would find the Squad repo itself. + root = mkdtempSync(join(tmpdir(), 'squad-hook-linked-')); + clearResolveSquadCache(); +}); + +afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllEnvs(); + clearResolveSquadCache(); + rmSync(root, { recursive: true, force: true }); +}); + +describe('squad hook claude in a linked project on a git state backend (#2320)', () => { + it.each(['orphan', 'two-layer'])('PreToolUse still warns a locked-out member editing the project on %s', async (stateBackend) => { + const team = join(root, 'team'); + const teamSquad = join(team, '.squad'); + mkdirSync(teamSquad, { recursive: true }); + writeFileSync(join(teamSquad, 'team.md'), '# Team\n'); + writeFileSync(join(teamSquad, 'config.json'), '{ "claudeHooks": "advisory" }\n'); + writeLockouts(teamSquad, rejectRevision(emptyLockoutStore(), { + subject: 'issue-42', author: 'dallas', reviewer: 'ripley', reason: 'missing tests', + paths: ['src/auth/**'], roster: ['dallas', 'lambert', 'ripley'], at: '2026-11-10T12:00:00.000Z', + })); + initRepo(team); + + const project = join(root, 'project'); + mkdirSync(join(project, '.squad'), { recursive: true }); + writeFileSync(join(project, '.squad', 'config.json'), JSON.stringify({ version: 1, teamRoot: '../team', stateBackend })); + initRepo(project); + vi.stubEnv('CLAUDE_PROJECT_DIR', project); + + const printed = await runHook('PreToolUse', { + hook_event_name: 'PreToolUse', + agent_type: 'dallas', + tool_name: 'Edit', + tool_input: { file_path: join(project, 'src', 'auth', 'login.ts') }, + cwd: project, + }); + + expect(printed).toMatch(/dallas is locked out of issue-42/); + expect(printed).toContain('src/auth/login.ts'); + }); +}); diff --git a/test/cli/state-mcp.test.ts b/test/cli/state-mcp.test.ts index beb8117b6..bcdca036b 100644 --- a/test/cli/state-mcp.test.ts +++ b/test/cli/state-mcp.test.ts @@ -1,9 +1,10 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; -import { clearResolveSquadCache } from '@bradygaster/squad-sdk'; +import { clearResolveSquadCache, resolveSquadState } from '@bradygaster/squad-sdk'; import { execFileSync } from 'node:child_process'; -import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; import { randomBytes } from 'node:crypto'; +import { tmpdir } from 'node:os'; import { createStateMcpSession } from '../../packages/squad-cli/src/cli/commands/state-mcp.js'; const TMP = join(process.cwd(), `.test-state-mcp-${randomBytes(4).toString('hex')}`); @@ -324,4 +325,68 @@ describe('state-mcp bridge', () => { expect(existsSync(inbox) ? readdirSync(inbox).length : 0, `teamRoot=../team: no decision in ${inbox}`).toBeGreaterThan(0); expect(existsSync(join(TMP, 'team', 'decisions')), 'teamRoot=../team: decision written outside .squad').toBe(false); }); + + it.each(['orphan', 'two-layer'] as const)( + "keeps a linked team's state on the team repo's %s backend (#2320)", + { timeout: 60_000 }, + async (stateBackend) => { + const team = join(TMP, 'team'); + const project = join(TMP, 'project'); + const gitIn = (cwd: string, args: string[]) => + execFileSync('git', ['-c', 'user.name=Test', '-c', 'user.email=test', ...args], { + cwd, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'], + }).trim(); + for (const dir of [team, project]) { + mkdirSync(join(dir, '.squad'), { recursive: true }); + writeFileSync(join(dir, 'README.md'), '# repo\n'); + gitIn(dir, ['init']); + } + writeFileSync(join(team, '.squad', 'team.md'), '# Team\n'); + writeFileSync(join(project, '.squad', 'config.json'), JSON.stringify({ version: 1, teamRoot: '../team', stateBackend })); + for (const dir of [team, project]) { + gitIn(dir, ['add', '-A']); + gitIn(dir, ['commit', '-m', 'init']); + } + + const messages: JsonRpcMessage[] = []; + const session = createStateMcpSession(project, message => messages.push(message as JsonRpcMessage)); + const call = async (name: string, args: Record) => { + await session.handleRequest({ jsonrpc: '2.0', id: messages.length, method: 'tools/call', params: { name, arguments: args } }); + return resultAsRecord(messages[messages.length - 1]!); + }; + + expect((await call('squad_state_write', { key: 'decisions/inbox/linked-proof.md', content: '# Linked proof\n' }))['isError']).not.toBe(true); + expect((await call('squad_state_read', { key: 'decisions/inbox/linked-proof.md' }))['content']) + .toEqual([{ type: 'text', text: '# Linked proof\n' }]); + expect((await call('memory.write', { content: 'Use Playwright for end-to-end tests', class: 'LOCAL' }))['isError']).not.toBe(true); + + // The state lives with the team repo, on its state branch, and nowhere in the project. + expect(gitIn(team, ['for-each-ref', '--format=%(refname)', 'refs/heads/squad-state'])).toBe('refs/heads/squad-state'); + expect(gitIn(project, ['for-each-ref', '--format=%(refname)', 'refs/heads/squad-state', 'refs/notes'])).toBe(''); + expect(existsSync(join(team, '.squad', 'decisions', 'inbox', 'linked-proof.md'))).toBe(false); + expect(gitIn(team, ['status', '--porcelain'])).toBe(''); + if (stateBackend === 'orphan') { + expect(gitIn(team, ['show', 'squad-state:decisions/inbox/linked-proof.md'])).toBe('# Linked proof'); + } + }, + ); + + it('fails clearly when a linked team on a git backend is not in a git repository (#2320)', () => { + // Outside this repo: anywhere under the checkout, `git rev-parse` would find it. + const root = mkdtempSync(join(tmpdir(), 'squad-linked-nogit-')); + try { + mkdirSync(join(root, 'team', '.squad'), { recursive: true }); + writeFileSync(join(root, 'team', '.squad', 'team.md'), '# Team\n'); + mkdirSync(join(root, 'project', '.squad'), { recursive: true }); + writeFileSync( + join(root, 'project', '.squad', 'config.json'), + JSON.stringify({ version: 1, teamRoot: '../team', stateBackend: 'orphan' }), + ); + + expect(() => resolveSquadState(join(root, 'project'))).toThrow(/team root is not in a git repository/); + } finally { + clearResolveSquadCache(); + rmSync(root, { recursive: true, force: true }); + } + }); }); diff --git a/test/linked-team-state-boundary.test.ts b/test/linked-team-state-boundary.test.ts new file mode 100644 index 000000000..f112eb1b3 --- /dev/null +++ b/test/linked-team-state-boundary.test.ts @@ -0,0 +1,223 @@ +/** + * A linked team on a git state backend writes its state into the team repo, and only + * into it (#2320). The team repo's git top level must be the team repo itself, compared + * canonically; containing the team dir is not enough. + */ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { execFileSync } from 'node:child_process'; +import { mkdirSync, mkdtempSync, realpathSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { clearResolveSquadCache, resolveSquadState } from '../packages/squad-sdk/src/resolution.js'; + +// Outside this checkout: anywhere under it, `git rev-parse` would find the Squad repo itself. +let root: string; + +const git = (cwd: string, args: string[]) => + execFileSync('git', ['-c', 'user.name=Test', '-c', 'user.email=test', ...args], { + cwd, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'], + }).trim(); + +function initRepo(dir: string): void { + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, 'README.md'), '# repo\n'); + git(dir, ['init']); + git(dir, ['add', '-A']); + git(dir, ['commit', '-m', 'init']); +} + +function makeTeam(dir: string): void { + mkdirSync(join(dir, '.squad'), { recursive: true }); + writeFileSync(join(dir, '.squad', 'team.md'), '# Team\n'); +} + +function makeProject(teamRoot: string, stateBackend = 'orphan'): string { + const project = join(root, 'project'); + mkdirSync(join(project, '.squad'), { recursive: true }); + writeFileSync(join(project, '.squad', 'config.json'), JSON.stringify({ version: 1, teamRoot, stateBackend })); + return project; +} + +function linkDir(target: string, link: string): void { + symlinkSync(target, link, process.platform === 'win32' ? 'junction' : 'dir'); +} + +const real = (p: string) => realpathSync.native(p); +const same = (a: string, b: string) => + process.platform === 'win32' ? a.toLowerCase() === b.toLowerCase() : a === b; + +/** The repository the resolved git backend runs in (the same fields state-backend-store.ts reads). */ +function stateRepo(ctx: NonNullable>): string { + const b = ctx.backend as unknown as { cwd?: string; repoRoot?: string; orphan?: { cwd?: string } }; + const dir = b.orphan?.cwd ?? b.cwd ?? b.repoRoot; + if (!dir) throw new Error(`backend ${ctx.backend.name} exposes no repository`); + return dir; +} + +/** No state ref of any git backend was created in `repo`. */ +function expectNoStateRefs(repo: string): void { + expect(git(repo, ['for-each-ref', '--format=%(refname)', 'refs/heads/squad-state', 'refs/notes'])).toBe(''); +} + +beforeEach(() => { + root = mkdtempSync(join(tmpdir(), 'squad-linked-boundary-')); + clearResolveSquadCache(); +}); + +afterEach(() => { + clearResolveSquadCache(); + rmSync(root, { recursive: true, force: true }); +}); + +describe('linked team on a git state backend: repository boundary (#2320)', () => { + describe('uses the team repo when its git root is the team repo', () => { + it.each([ + ['a relative teamRoot', () => '../team'], + ['an absolute teamRoot', () => join(root, 'team')], + ["a teamRoot that names the team's .squad dir", () => '../team/.squad'], + ['a teamRoot with a .. segment that lands on the team repo', () => '../other/../team'], + ])('%s', (_label, teamRoot) => { + const team = join(root, 'team'); + makeTeam(team); + initRepo(team); + mkdirSync(join(root, 'other')); + const project = makeProject(teamRoot()); + + const ctx = resolveSquadState(project); + expect(ctx).not.toBeNull(); + expect(same(real(stateRepo(ctx!)), real(team)), `${stateRepo(ctx!)} vs ${real(team)}`).toBe(true); + }); + + it('a team repo that is a linked git worktree', () => { + const main = join(root, 'team-main'); + makeTeam(main); + initRepo(main); + const worktree = join(root, 'team-wt'); + git(main, ['worktree', 'add', '-b', 'team-wt', worktree]); + const project = makeProject('../team-wt'); + + const ctx = resolveSquadState(project); + expect(same(real(stateRepo(ctx!)), real(worktree)), `${stateRepo(ctx!)} vs ${real(worktree)}`).toBe(true); + }); + + it('a teamRoot that is a symlink or junction to a team repo of its own', () => { + const team = join(root, 'team'); + makeTeam(team); + initRepo(team); + linkDir(team, join(root, 'team-link')); + const project = makeProject('../team-link'); + + const ctx = resolveSquadState(project); + expect(same(real(stateRepo(ctx!)), real(team)), `${stateRepo(ctx!)} vs ${real(team)}`).toBe(true); + }); + + it.runIf(process.platform === 'win32').each([ + ['the team repo', ''], + ["the team's .squad dir (spelled .SQUAD)", '\\.SQUAD'], + ])('[platform: win32] a teamRoot naming %s, with a different drive-letter and path case', (_label, suffix) => { + const team = join(root, 'team'); + makeTeam(team); + initRepo(team); + const absolute = real(team); + const respelled = absolute[0]!.toLowerCase() + absolute.slice(1).toUpperCase() + suffix; + expect(respelled.slice(0, absolute.length)).not.toBe(absolute); + const project = makeProject(respelled); + + const ctx = resolveSquadState(project); + expect(same(real(stateRepo(ctx!)), absolute), `${stateRepo(ctx!)} vs ${absolute}`).toBe(true); + }); + }); + + describe("keeps repoRoot at the project's repository; only the state backend moves to the team repo", () => { + it.each(['orphan', 'two-layer'])('%s', (stateBackend) => { + const team = join(root, 'team'); + makeTeam(team); + initRepo(team); + const project = makeProject('../team', stateBackend); + initRepo(project); + + const ctx = resolveSquadState(project)!; + expect(same(real(ctx.repoRoot), real(project)), `${ctx.repoRoot} vs ${real(project)}`).toBe(true); + expect(same(real(stateRepo(ctx)), real(team)), `${stateRepo(ctx)} vs ${real(team)}`).toBe(true); + }); + }); + + describe('fails closed instead of writing into another repository', () => { + const outer = () => join(root, 'outer'); + const nestedTeam = () => join(outer(), 'teams', 'team'); + const setupNested = () => { + initRepo(outer()); + makeTeam(nestedTeam()); + }; + + it.each([ + ['a relative teamRoot', () => '../outer/teams/team'], + ['an absolute teamRoot', () => nestedTeam()], + ['a teamRoot with .. traversal', () => '../outer/teams/x/../team'], + ])('a non-git team dir inside another repository, via %s', (_label, teamRoot) => { + setupNested(); + const project = makeProject(teamRoot()); + + expect(() => resolveSquadState(project)).toThrow(/is not the root of a git repository: it is inside the repository at/); + expectNoStateRefs(outer()); + }); + + it.each(['orphan', 'two-layer'])('the same refusal on the %s backend', (stateBackend) => { + setupNested(); + const project = makeProject('../outer/teams/team', stateBackend); + + expect(() => resolveSquadState(project)).toThrow(/is not the root of a git repository/); + expectNoStateRefs(outer()); + }); + + it('a teamRoot symlink or junction to a non-git team dir inside another repository', () => { + setupNested(); + linkDir(nestedTeam(), join(root, 'team-link')); + const project = makeProject('../team-link'); + + expect(() => resolveSquadState(project)).toThrow(/is not the root of a git repository/); + expectNoStateRefs(outer()); + }); + + it("a team .squad that is a symlink or junction into another repository", () => { + const other = join(root, 'other-repo'); + makeTeam(other); + initRepo(other); + const team = join(root, 'team'); + initRepo(team); + linkDir(join(other, '.squad'), join(team, '.squad')); + const project = makeProject('../team'); + + expect(() => resolveSquadState(project)).toThrow(/squad dir resolves to .*outside the team repo/); + expectNoStateRefs(other); + expectNoStateRefs(team); + }); + + it('a team root that is not in any git repository', () => { + makeTeam(join(root, 'team')); + const project = makeProject('../team'); + + expect(() => resolveSquadState(project)).toThrow(/team root is not in a git repository/); + }); + + it('a team root that does not exist', () => { + const project = makeProject('../missing-team'); + + expect(() => resolveSquadState(project)).toThrow(/team root does not exist or cannot be resolved/); + }); + }); + + it("still refuses to modify or delete the team's state root", () => { + const team = join(root, 'team'); + makeTeam(team); + initRepo(team); + const project = makeProject('../team'); + + const ctx = resolveSquadState(project)!; + const rootError = /Squad state root.*cannot be modified or deleted/i; + expect(() => ctx.storage.deleteSync(ctx.paths.teamSquadDir)).toThrow(rootError); + expect(() => ctx.storage.deleteDirSync(ctx.paths.teamSquadDir)).toThrow(rootError); + expect(() => ctx.storage.writeSync(ctx.paths.teamSquadDir, 'x')).toThrow(rootError); + expect(dirname(ctx.paths.teamSquadDir)).toBe(team); + }); +});