From aa2e03304d15449f2ed792d25696f924934092fb Mon Sep 17 00:00:00 2001 From: Jose Luis Latorre Millas Date: Mon, 26 Oct 2026 19:19:50 +0100 Subject: [PATCH 1/3] fix(sdk): keep a linked team's state on the team repo's git backend In a linked project on the orphan or two-layer backend, resolveSquadState ran the git backend in the project repo and rooted the storage adapter at the project's .squad, while every squad_state tool builds paths under the team's .squad. Each memory.*, squad_state_write and squad_state_read call failed with "path is outside squadDir". The team repo owns the state: - in linked mode with a git backend, the backend runs in the team repo. Its git top level must be exactly the team repo (a worktree root counts), compared after resolving links, drive letters and case. A team dir that only sits inside another repository, through "..", an absolute teamRoot or a symlink/junction, and a team .squad linked out of the team repo are refused, as is a team root outside any git repository; - only the backend moves: the context's repoRoot stays the project's repository, which the Claude hooks and the backend MCP harnesses use for project paths and agent turns; - the adapter is rooted at the team's .squad, the root the local backend and the tools already use (unchanged outside linked mode); - the config parsing in resolveStateBackend moves to configuredStateBackend() so the type can be checked first. Closes #2320 --- .changeset/linked-team-git-backend.md | 5 + .../content/docs/features/state-backends.md | 2 + packages/squad-sdk/src/resolution.ts | 98 +++++++- packages/squad-sdk/src/state-backend.ts | 19 +- test/cli/hook-linked-project.test.ts | 87 +++++++ test/cli/state-mcp.test.ts | 69 +++++- test/linked-team-state-boundary.test.ts | 223 ++++++++++++++++++ 7 files changed, 493 insertions(+), 10 deletions(-) create mode 100644 .changeset/linked-team-git-backend.md create mode 100644 test/cli/hook-linked-project.test.ts create mode 100644 test/linked-team-state-boundary.test.ts 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/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..df6c27430 --- /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'], + ])('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); + }); +}); From f7ff0ececc425e65e6907d92b7d782e1c4b2153d Mon Sep 17 00:00:00 2001 From: Jose Luis Latorre Millas Date: Tue, 10 Nov 2026 22:21:48 +0100 Subject: [PATCH 2/3] test: register the Windows-only linked-team casing tests with release reconciliation The two drive-letter/case tests run only on Windows (it.runIf win32), so Linux release evidence reported them as unexplained skips. Following the repo's [platform: win32] convention, they get a title prefix and a reviewed env-gated rule with runsOn: 'win32', which also makes them required, passing executions in Windows evidence. --- scripts/release/test-reconciliation.mjs | 10 ++++++++++ test/linked-team-state-boundary.test.ts | 2 +- test/test-reconciliation.test.ts | 14 ++++++++++++++ 3 files changed, 25 insertions(+), 1 deletion(-) diff --git a/scripts/release/test-reconciliation.mjs b/scripts/release/test-reconciliation.mjs index e20833568..6c4d15c30 100644 --- a/scripts/release/test-reconciliation.mjs +++ b/scripts/release/test-reconciliation.mjs @@ -65,6 +65,16 @@ export const CATEGORY_RULES = Object.freeze([ runsOn: 'non-win32', reason: "it.runIf(process.platform !== 'win32'): POSIX-only legacy symlink-hash reconciliation", }, + ...[ + '[platform: win32] a teamRoot naming the team repo, with a different drive-letter and path case', + "[platform: win32] a teamRoot naming the team's .squad dir (spelled .SQUAD), with a different drive-letter and path case", + ].map((title) => ({ + category: 'env-gated', + file: 'test/linked-team-state-boundary.test.ts', + title, + runsOn: 'win32', + reason: "it.runIf(process.platform === '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/linked-team-state-boundary.test.ts b/test/linked-team-state-boundary.test.ts index df6c27430..f112eb1b3 100644 --- a/test/linked-team-state-boundary.test.ts +++ b/test/linked-team-state-boundary.test.ts @@ -114,7 +114,7 @@ describe('linked team on a git state backend: repository boundary (#2320)', () = it.runIf(process.platform === 'win32').each([ ['the team repo', ''], ["the team's .squad dir (spelled .SQUAD)", '\\.SQUAD'], - ])('a teamRoot naming %s, with a different drive-letter and path case', (_label, suffix) => { + ])('[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); diff --git a/test/test-reconciliation.test.ts b/test/test-reconciliation.test.ts index 4fb1205e2..2d2ace33b 100644 --- a/test/test-reconciliation.test.ts +++ b/test/test-reconciliation.test.ts @@ -103,9 +103,14 @@ describe('release test reconciliation', () => { it('requires the Windows-only test to be present and passed in Windows evidence', () => { const title = '[platform: win32] removes the legacy raw uppercase Windows drive hash while preserving unrelated entries'; const memoryTitle = '[platform: win32] retries transient Windows index rename sharing violations'; + const linkedTitles = [ + '[platform: win32] a teamRoot naming the team repo, with a different drive-letter and path case', + "[platform: win32] a teamRoot naming the team's .squad dir (spelled .SQUAD), with a different drive-letter and path case", + ]; const result = reconcile(reportForMany([ { file: 'D:\\a\\squad\\squad\\test\\mcp-root-write.test.ts', title, status: 'passed' }, { file: 'D:\\a\\squad\\squad\\test\\memory-providers.test.ts', title: memoryTitle, status: 'passed' }, + ...linkedTitles.map((linkedTitle) => ({ file: 'D:\\a\\squad\\squad\\test\\linked-team-state-boundary.test.ts', title: linkedTitle, status: 'passed' as const })), ]), { platform: 'win32', requirePlatformExecutions: true, @@ -126,6 +131,13 @@ describe('release test reconciliation', () => { status: 'passed', ok: true, }, + ...linkedTitles.map((linkedTitle) => ({ + file: 'test/linked-team-state-boundary.test.ts', + title: linkedTitle, + platform: 'win32', + status: 'passed', + ok: true, + })), ]); expect(result.summary.ok).toBe(true); expect(shouldFail(result, { failOnUnexplained: true })).toBe(false); @@ -140,6 +152,8 @@ describe('release test reconciliation', () => { expect(result.summary.requiredExecutions).toEqual([ expect.objectContaining({ file: 'test/mcp-root-write.test.ts', platform: 'win32', status: 'missing', ok: false }), expect.objectContaining({ file: 'test/memory-providers.test.ts', platform: 'win32', status: 'missing', ok: false }), + expect.objectContaining({ file: 'test/linked-team-state-boundary.test.ts', platform: 'win32', status: 'missing', ok: false }), + expect.objectContaining({ file: 'test/linked-team-state-boundary.test.ts', platform: 'win32', status: 'missing', ok: false }), ]); expect(result.summary.ok).toBe(false); expect(shouldFail(result, { failOnUnexplained: true })).toBe(true); From 219f9889aa8b24ef175fe4f1e9079a488500ac3e Mon Sep 17 00:00:00 2001 From: Jose Luis Latorre Millas Date: Tue, 10 Nov 2026 22:39:09 +0100 Subject: [PATCH 3/3] test: gate the linked-team casing tests as Windows-only without requiring them in the Windows release job The Windows release job runs a fixed list of test files, so a runsOn rule made these two tests required there and reported them missing. Use the same plain env-gated rule as the other it.runIf(win32) tests (mcp-spec-init) and leave the release workflow untouched. --- scripts/release/test-reconciliation.mjs | 11 +---------- test/test-reconciliation.test.ts | 14 -------------- 2 files changed, 1 insertion(+), 24 deletions(-) diff --git a/scripts/release/test-reconciliation.mjs b/scripts/release/test-reconciliation.mjs index 6c4d15c30..a21c69a89 100644 --- a/scripts/release/test-reconciliation.mjs +++ b/scripts/release/test-reconciliation.mjs @@ -65,16 +65,7 @@ export const CATEGORY_RULES = Object.freeze([ runsOn: 'non-win32', reason: "it.runIf(process.platform !== 'win32'): POSIX-only legacy symlink-hash reconciliation", }, - ...[ - '[platform: win32] a teamRoot naming the team repo, with a different drive-letter and path case', - "[platform: win32] a teamRoot naming the team's .squad dir (spelled .SQUAD), with a different drive-letter and path case", - ].map((title) => ({ - category: 'env-gated', - file: 'test/linked-team-state-boundary.test.ts', - title, - runsOn: 'win32', - reason: "it.runIf(process.platform === 'win32'): Windows-only drive-letter and case-insensitive team root resolution", - })), + { 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/test-reconciliation.test.ts b/test/test-reconciliation.test.ts index 2d2ace33b..4fb1205e2 100644 --- a/test/test-reconciliation.test.ts +++ b/test/test-reconciliation.test.ts @@ -103,14 +103,9 @@ describe('release test reconciliation', () => { it('requires the Windows-only test to be present and passed in Windows evidence', () => { const title = '[platform: win32] removes the legacy raw uppercase Windows drive hash while preserving unrelated entries'; const memoryTitle = '[platform: win32] retries transient Windows index rename sharing violations'; - const linkedTitles = [ - '[platform: win32] a teamRoot naming the team repo, with a different drive-letter and path case', - "[platform: win32] a teamRoot naming the team's .squad dir (spelled .SQUAD), with a different drive-letter and path case", - ]; const result = reconcile(reportForMany([ { file: 'D:\\a\\squad\\squad\\test\\mcp-root-write.test.ts', title, status: 'passed' }, { file: 'D:\\a\\squad\\squad\\test\\memory-providers.test.ts', title: memoryTitle, status: 'passed' }, - ...linkedTitles.map((linkedTitle) => ({ file: 'D:\\a\\squad\\squad\\test\\linked-team-state-boundary.test.ts', title: linkedTitle, status: 'passed' as const })), ]), { platform: 'win32', requirePlatformExecutions: true, @@ -131,13 +126,6 @@ describe('release test reconciliation', () => { status: 'passed', ok: true, }, - ...linkedTitles.map((linkedTitle) => ({ - file: 'test/linked-team-state-boundary.test.ts', - title: linkedTitle, - platform: 'win32', - status: 'passed', - ok: true, - })), ]); expect(result.summary.ok).toBe(true); expect(shouldFail(result, { failOnUnexplained: true })).toBe(false); @@ -152,8 +140,6 @@ describe('release test reconciliation', () => { expect(result.summary.requiredExecutions).toEqual([ expect.objectContaining({ file: 'test/mcp-root-write.test.ts', platform: 'win32', status: 'missing', ok: false }), expect.objectContaining({ file: 'test/memory-providers.test.ts', platform: 'win32', status: 'missing', ok: false }), - expect.objectContaining({ file: 'test/linked-team-state-boundary.test.ts', platform: 'win32', status: 'missing', ok: false }), - expect.objectContaining({ file: 'test/linked-team-state-boundary.test.ts', platform: 'win32', status: 'missing', ok: false }), ]); expect(result.summary.ok).toBe(false); expect(shouldFail(result, { failOnUnexplained: true })).toBe(true);