diff --git a/.changeset/git-state-backends.md b/.changeset/git-state-backends.md new file mode 100644 index 00000000..994e0346 --- /dev/null +++ b/.changeset/git-state-backends.md @@ -0,0 +1,17 @@ +--- +"@bradygaster/squad-sdk": minor +"@bradygaster/squad-cli": minor +--- + +feat(sdk): git-notes + orphan-branch state backends for .squad/ + +Adds two git-native state storage backends as alternatives to the worktree +and external directory approaches: + +- **git-notes** (`refs/notes/squad`): State stored in git notes ref. Survives + branch switches, invisible in diffs and PRs. +- **orphan-branch** (`squad-state`): Dedicated orphan branch with no common + ancestor. State files never appear in main. + +Configure via `.squad/config.json`: `{ "stateBackend": "git-notes" }` or +the `--state-backend` CLI flag. \ No newline at end of file diff --git a/packages/squad-cli/src/cli-entry.ts b/packages/squad-cli/src/cli-entry.ts index 3c7e970c..02d87e8c 100644 --- a/packages/squad-cli/src/cli-entry.ts +++ b/packages/squad-cli/src/cli-entry.ts @@ -250,6 +250,7 @@ async function main(): Promise { console.log(` ${BOLD}--global${RESET} Use personal (global) squad path (for init, upgrade)`); console.log(` ${BOLD}--economy${RESET} Activate economy mode for this session (cheaper models)`); console.log(` ${BOLD}--team-root${RESET} Override team root path for resolution`); + console.log(` ${BOLD}--state-backend${RESET} State storage: worktree | external | git-notes | orphan`); console.log(`\nInstallation:`); console.log(` npm install --save-dev @bradygaster/squad-cli`); console.log(`\nInsider channel:`); @@ -295,8 +296,33 @@ async function main(): Promise { const noWorkflows = args.includes('--no-workflows'); const sdk = args.includes('--sdk'); const roles = args.includes('--roles'); + + // --state-backend: write stateBackend into .squad/config.json on init + const stateBackendIdx = args.indexOf('--state-backend'); + const stateBackendVal = (stateBackendIdx !== -1 && args[stateBackendIdx + 1]) + ? args[stateBackendIdx + 1] + : undefined; + // Global init: suppress workflows (no GitHub CI in ~/.config/squad/) and bootstrap personal squad - runInit(dest, { includeWorkflows: !noWorkflows && !hasGlobal, sdk, roles, isGlobal: hasGlobal }).catch(err => { + runInit(dest, { includeWorkflows: !noWorkflows && !hasGlobal, sdk, roles, isGlobal: hasGlobal }).then(async () => { + if (stateBackendVal) { + const { join } = await import('node:path'); + const { existsSync, readFileSync, writeFileSync, mkdirSync } = await import('node:fs'); + const squadDir = join(dest, '.squad'); + if (!existsSync(squadDir)) mkdirSync(squadDir, { recursive: true }); + const configPath = join(squadDir, 'config.json'); + // Read existing config first, then merge (avoids overwriting unrelated keys) + let config: Record = {}; + try { + if (existsSync(configPath)) { + config = JSON.parse(readFileSync(configPath, 'utf-8')); + } + } catch { /* fresh config */ } + config['stateBackend'] = stateBackendVal; + writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n'); + console.log(`✓ State backend set to '${stateBackendVal}' in .squad/config.json`); + } + }).catch(err => { fatal(err.message); }); return; @@ -377,6 +403,12 @@ async function main(): Promise { if (args.includes(`--no-${cap.name}`)) capabilities[cap.name] = false; } + // --state-backend flag for watch command + const watchStateBackendIdx = args.indexOf('--state-backend'); + const rawWatchStateBackend = (watchStateBackendIdx !== -1 && args[watchStateBackendIdx + 1]) + ? args[watchStateBackendIdx + 1] as string + : undefined; + // Legacy flag compat: --board-project sets board sub-option const boardProjectIdx = args.indexOf('--board-project'); if (boardProjectIdx !== -1 && args[boardProjectIdx + 1]) { @@ -394,6 +426,7 @@ async function main(): Promise { timeout, copilotFlags, agentCmd, + stateBackend: watchStateBackend as any, capabilities: Object.keys(capabilities).length > 0 ? capabilities : undefined, }); diff --git a/packages/squad-cli/src/cli/commands/watch/config.ts b/packages/squad-cli/src/cli/commands/watch/config.ts index ac7509d7..faca52a4 100644 --- a/packages/squad-cli/src/cli/commands/watch/config.ts +++ b/packages/squad-cli/src/cli/commands/watch/config.ts @@ -18,6 +18,8 @@ export interface WatchConfig { copilotFlags?: string; /** Hidden — fully override the agent command. */ agentCmd?: string; + /** State storage backend: worktree | external | git-notes | orphan */ + stateBackend?: string; /** Per-capability config: `true` / `false` / object with sub-options. */ capabilities: Record>; } @@ -63,6 +65,7 @@ export function loadWatchConfig( timeout: cliOverrides.timeout ?? fileConfig.timeout ?? DEFAULTS.timeout, copilotFlags: cliOverrides.copilotFlags ?? fileConfig.copilotFlags ?? DEFAULTS.copilotFlags, agentCmd: cliOverrides.agentCmd ?? fileConfig.agentCmd ?? DEFAULTS.agentCmd, + stateBackend: cliOverrides.stateBackend ?? fileConfig.stateBackend ?? DEFAULTS.stateBackend, capabilities: { ...DEFAULTS.capabilities, ...(fileConfig.capabilities ?? {}), @@ -83,10 +86,11 @@ function normalizeFileConfig(raw: Record): Partial if (typeof raw['timeout'] === 'number') result.timeout = raw['timeout']; if (typeof raw['copilotFlags'] === 'string') result.copilotFlags = raw['copilotFlags']; if (typeof raw['agentCmd'] === 'string') result.agentCmd = raw['agentCmd']; + if (typeof raw['stateBackend'] === 'string') result.stateBackend = raw['stateBackend']; // Everything else is a capability key const caps: Record> = {}; - const reserved = new Set(['interval', 'execute', 'maxConcurrent', 'timeout', 'copilotFlags', 'agentCmd']); + const reserved = new Set(['interval', 'execute', 'maxConcurrent', 'timeout', 'copilotFlags', 'agentCmd', 'stateBackend']); for (const [key, value] of Object.entries(raw)) { if (reserved.has(key)) continue; if (typeof value === 'boolean' || (typeof value === 'object' && value !== null && !Array.isArray(value))) { diff --git a/packages/squad-sdk/src/index.ts b/packages/squad-sdk/src/index.ts index 185a192a..9848741a 100644 --- a/packages/squad-sdk/src/index.ts +++ b/packages/squad-sdk/src/index.ts @@ -103,6 +103,10 @@ export * from './roles/index.js'; export * from './platform/index.js'; export * from './storage/index.js'; +// Git-native state backends (Issue #807) +export type { StateBackend, StateBackendType, StateBackendConfig } from './state-backend.js'; +export { WorktreeBackend, GitNotesBackend, OrphanBranchBackend, resolveStateBackend } from './state-backend.js'; + // State facade (Phase 2) — namespaced to avoid conflicts with existing config/sharing exports export { // Error classes diff --git a/packages/squad-sdk/src/resolution.ts b/packages/squad-sdk/src/resolution.ts index fbffaf46..781db399 100644 --- a/packages/squad-sdk/src/resolution.ts +++ b/packages/squad-sdk/src/resolution.ts @@ -34,6 +34,8 @@ export interface SquadDirConfig { consult?: boolean; /** True when extraction is disabled for consult sessions (read-only consultation) */ extractionDisabled?: boolean; + /** State storage backend: worktree | external | git-notes | orphan */ + stateBackend?: string; } /** @@ -222,6 +224,7 @@ export function loadDirConfig(squadDir: string): SquadDirConfig | null { projectKey: typeof parsed.projectKey === 'string' ? parsed.projectKey : null, consult: parsed.consult === true ? true : undefined, extractionDisabled: parsed.extractionDisabled === true ? true : undefined, + stateBackend: typeof parsed.stateBackend === 'string' ? parsed.stateBackend : undefined, }; } return null; diff --git a/packages/squad-sdk/src/state-backend.ts b/packages/squad-sdk/src/state-backend.ts new file mode 100644 index 00000000..9babcf26 --- /dev/null +++ b/packages/squad-sdk/src/state-backend.ts @@ -0,0 +1,270 @@ +/** + * Git-native state backends for `.squad/` state storage. + * + * @module state-backend + */ + +import { execSync, execFileSync } from 'node:child_process'; +import path from 'node:path'; +import { FSStorageProvider } from './storage/fs-storage-provider.js'; + +const storage = new FSStorageProvider(); + +export type StateBackendType = 'worktree' | 'external' | 'git-notes' | 'orphan'; + +export interface StateBackend { + read(relativePath: string): string | undefined; + write(relativePath: string, content: string): void; + exists(relativePath: string): boolean; + list(relativeDir: string): string[]; + readonly name: string; +} + +export class WorktreeBackend implements StateBackend { + if (relativePath.includes('..')) throw new Error('Path traversal not allowed'); + if (relativePath.includes('..')) throw new Error('Path traversal not allowed'); + if (relativePath.includes('..')) throw new Error('Path traversal not allowed'); + readonly name = 'worktree'; + private readonly root: string; + constructor(squadDir: string) { this.root = squadDir; } + read(relativePath: string): string | undefined { + return storage.readSync(path.join(this.root, relativePath)) ?? undefined; + } + write(relativePath: string, content: string): void { + storage.writeSync(path.join(this.root, relativePath), content); + } + exists(relativePath: string): boolean { + return storage.existsSync(path.join(this.root, relativePath)); + } + list(relativeDir: string): string[] { + const full = path.join(this.root, relativeDir); + if (!storage.existsSync(full) || !storage.isDirectorySync(full)) return []; + return storage.listSync(full); + } +} + +function gitExec(args: string, cwd: string): string | null { + try { + return execFileSync('git', args.split(' '), { cwd, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }).trim(); + } catch { return null; } +} + +function gitExecContent(args, cwd) { + try { + return execFileSync('git', args.split(' '), { cwd, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }).trimEnd(); + } catch { return null; } +} + +function gitExecOrThrow(args: string, cwd: string): string { + const result = gitExec(args, cwd); + if (result === null) throw new Error(`git command failed: git ${args}`); + return result; +} + +function normalizeKey(relativePath: string): string { + return relativePath.replace(/\\/g, '/').replace(/^\/+/, '').replace(/\/+$/, ''); +} + +export class GitNotesBackend implements StateBackend { + readonly name = 'git-notes'; + private readonly cwd: string; + private readonly ref = 'squad'; + constructor(repoRoot: string) { this.cwd = repoRoot; } + + private loadBlob(): Record { + const raw = gitExec(`notes --ref=${this.ref} show HEAD`, this.cwd); + if (!raw) return {}; + try { + const parsed: unknown = JSON.parse(raw); + if (parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed)) { + return parsed as Record; + } + return {}; + } catch { return {}; } + } + + private saveBlob(blob: Record): void { + const json = JSON.stringify(blob, null, 2); + try { + execSync(`git notes --ref=${this.ref} add -f --file - HEAD`, { + cwd: this.cwd, input: json, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'], + }); + } catch { throw new Error('git-notes backend: failed to write note on HEAD'); } + } + + read(relativePath: string): string | undefined { + const blob = this.loadBlob(); + return blob[normalizeKey(relativePath)]; + } + write(relativePath: string, content: string): void { + const blob = this.loadBlob(); + blob[normalizeKey(relativePath)] = content; + this.saveBlob(blob); + } + exists(relativePath: string): boolean { + return Object.hasOwn(this.loadBlob(), normalizeKey(relativePath)); + } + list(relativeDir: string): string[] { + const blob = this.loadBlob(); + const normalized = normalizeKey(relativeDir); + const dirPrefix = normalized ? normalized + '/' : ''; + const entries = new Set(); + for (const key of Object.keys(blob)) { + if (key.startsWith(dirPrefix)) { + const rest = key.slice(dirPrefix.length); + const slash = rest.indexOf('/'); + entries.add(slash === -1 ? rest : rest.slice(0, slash)); + } + } + return [...entries].sort(); + } +} + +export class OrphanBranchBackend implements StateBackend { + readonly name = 'orphan'; + private readonly cwd: string; + private readonly branch: string; + constructor(repoRoot: string, branch = 'squad-state') { + this.cwd = repoRoot; this.branch = branch; + } + + private ensureBranch(): void { + if (gitExec(`rev-parse --verify refs/heads/${this.branch}`, this.cwd)) return; + let tree: string; + try { + tree = execSync('git mktree', { cwd: this.cwd, input: '', encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }).trim(); + } catch { throw new Error('orphan backend: failed to create empty tree'); } + let commit: string; + try { + commit = execSync(`git commit-tree ${tree} -m "Initialize squad-state branch"`, { + cwd: this.cwd, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'], + }).trim(); + } catch { throw new Error('orphan backend: failed to create initial commit'); } + gitExecOrThrow(`update-ref refs/heads/${this.branch} ${commit}`, this.cwd); + } + + read(relativePath: string): string | undefined { + const result = gitExec(`show ${this.branch}:${normalizeKey(relativePath)}`, this.cwd); + return result ?? undefined; + } + + write(relativePath: string, content: string): void { + this.ensureBranch(); + const key = normalizeKey(relativePath); + let blobHash: string; + try { + blobHash = execSync('git hash-object -w --stdin', { + cwd: this.cwd, input: content, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'], + }).trim(); + } catch { throw new Error(`orphan backend: failed to hash content for ${key}`); } + + let currentTree: string; + const treeResult = gitExec(`log --format=%T -1 ${this.branch}`, this.cwd); + if (!treeResult) { + try { + currentTree = execSync('git mktree', { cwd: this.cwd, input: '', encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }).trim(); + } catch { throw new Error('orphan backend: failed to create empty tree'); } + } else { currentTree = treeResult; } + + const newTree = this.updateTree(currentTree, key.split('/'), blobHash); + const parentCommit = gitExec(`rev-parse ${this.branch}`, this.cwd); + let newCommit: string; + try { + const parentArg = parentCommit ? `-p ${parentCommit}` : ''; + newCommit = execSync(`git commit-tree ${newTree} ${parentArg} -m "Update ${key}"`, { + cwd: this.cwd, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'], + }).trim(); + } catch { throw new Error(`orphan backend: failed to commit update for ${key}`); } + gitExecOrThrow(`update-ref refs/heads/${this.branch} ${newCommit}`, this.cwd); + } + + exists(relativePath: string): boolean { + return gitExec(`cat-file -t ${this.branch}:${normalizeKey(relativePath)}`, this.cwd) !== null; + } + + list(relativeDir: string): string[] { + const key = normalizeKey(relativeDir); + const target = key ? `${this.branch}:${key}` : `${this.branch}:`; + const result = gitExec(`ls-tree --name-only ${target}`, this.cwd); + if (!result) return []; + return result.split('\n').filter(Boolean); + } + + private updateTree(baseTree: string, pathSegments: string[], blobHash: string): string { + if (pathSegments.length === 0) throw new Error('orphan backend: empty path segments'); + if (pathSegments.length === 1) { + return this.replaceEntry(baseTree, pathSegments[0]!, '100644', 'blob', blobHash); + } + const [dir, ...rest] = pathSegments; + const subTreeHash = this.getSubtreeHash(baseTree, dir!); + let childTree: string; + if (subTreeHash) { + childTree = this.updateTree(subTreeHash, rest, blobHash); + } else { + const emptyTree = execSync('git mktree', { cwd: this.cwd, input: '', encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }).trim(); + childTree = this.updateTree(emptyTree, rest, blobHash); + } + return this.replaceEntry(baseTree, dir!, '040000', 'tree', childTree); + } + + private getSubtreeHash(treeHash: string, name: string): string | null { + const listing = gitExec(`ls-tree ${treeHash}`, this.cwd); + if (!listing) return null; + for (const line of listing.split('\n')) { + const match = line.match(/^(\d+)\s+(blob|tree)\s+([a-f0-9]+)\t(.+)$/); + if (match && match[4] === name && match[2] === 'tree') return match[3]!; + } + return null; + } + + private replaceEntry(treeHash: string, name: string, mode: string, type: string, hash: string): string { + const listing = gitExec(`ls-tree ${treeHash}`, this.cwd) ?? ''; + const lines = listing.split('\n').filter(Boolean); + const filtered = lines.filter((line) => { + const match = line.match(/^(\d+)\s+(blob|tree)\s+([a-f0-9]+)\t(.+)$/); + return !(match && match[4] === name); + }); + filtered.push(`${mode} ${type} ${hash}\t${name}`); + try { + return execSync('git mktree', { cwd: this.cwd, input: filtered.join('\n') + '\n', encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }).trim(); + } catch { throw new Error(`orphan backend: failed to create tree with entry ${name}`); } + } +} + +export interface StateBackendConfig { stateBackend?: StateBackendType; } + +export function resolveStateBackend(squadDir: string, repoRoot: string, cliOverride?: StateBackendType): StateBackend { + let configBackend: StateBackendType | undefined; + try { + const configPath = path.join(squadDir, 'config.json'); + const raw = storage.readSync(configPath); + if (raw) { + const parsed = JSON.parse(raw) as Record; + if (typeof parsed['stateBackend'] === 'string' && isValidBackendType(parsed['stateBackend'])) { + configBackend = parsed['stateBackend'] as StateBackendType; + } + } + } catch { /* fall through */ } + const chosen = cliOverride ?? configBackend ?? 'worktree'; + try { + return createBackend(chosen, squadDir, repoRoot); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + console.warn(`Warning: State backend '${chosen}' failed: ${msg}. Falling back to 'worktree'.`); + return new WorktreeBackend(squadDir); + } +} + +function isValidBackendType(value: string): value is StateBackendType { + return ['worktree', 'external', 'git-notes', 'orphan'].includes(value); +} + +function createBackend(type: StateBackendType, squadDir: string, repoRoot: string): StateBackend { + switch (type) { + case 'worktree': return new WorktreeBackend(squadDir); + case 'git-notes': return new GitNotesBackend(repoRoot); + case 'orphan': return new OrphanBranchBackend(repoRoot); + case 'external': return new WorktreeBackend(squadDir); // Stub — PR #797 + default: throw new Error(`Unknown state backend type: ${type}`); + } +} \ No newline at end of file diff --git a/test/cli/upgrade.test.ts b/test/cli/upgrade.test.ts index 1ccd89a7..af1b8f3b 100644 --- a/test/cli/upgrade.test.ts +++ b/test/cli/upgrade.test.ts @@ -353,4 +353,23 @@ describe('CLI: upgrade command', () => { expect(forceResult.filesUpdated.length).toBeGreaterThan(0); expect(forceResult.filesUpdated).toContain('squad.agent.md'); }); + + /* ── --self flag (selfUpgradeCli) ──────────────────────────── */ + + it('selfUpgradeCli shells out with correct package tag', async () => { + const childProcess = await import('node:child_process'); + const execFileSyncSpy = vi.spyOn(childProcess, 'execFileSync').mockImplementation(() => Buffer.from('')); + + await selfUpgradeCli({ insider: true }); + + // First call is the install command, subsequent calls may be version check + expect(execFileSyncSpy).toHaveBeenCalled(); + const firstCall = execFileSyncSpy.mock.calls[0]!; + const cmd = firstCall[0] as string; + const cmdArgs = firstCall[1] as string[]; + expect(cmdArgs.some(a => a.includes('@bradygaster/squad-cli@insider'))).toBe(true); + expect(['npm', 'pnpm', 'yarn']).toContain(cmd); + + execFileSyncSpy.mockRestore(); + }); }); diff --git a/test/state-backend.test.ts b/test/state-backend.test.ts new file mode 100644 index 00000000..40b47154 --- /dev/null +++ b/test/state-backend.test.ts @@ -0,0 +1,96 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdirSync, rmSync, writeFileSync, readFileSync, existsSync } from 'node:fs'; +import { join } from 'node:path'; +import { execSync } from 'node:child_process'; +import { randomBytes } from 'node:crypto'; +import { WorktreeBackend, GitNotesBackend, OrphanBranchBackend, resolveStateBackend } from '../packages/squad-sdk/src/state-backend.js'; +import type { StateBackendType } from '../packages/squad-sdk/src/state-backend.js'; + +const TMP = join(process.cwd(), `.test-state-backend-${randomBytes(4).toString('hex')}`); +function git(args: string, cwd = TMP): string { + return execSync(`git ${args}`, { cwd, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }).trim(); +} +function initRepo(): void { + mkdirSync(TMP, { recursive: true }); + git('init'); git('config user.email "test@test.com"'); git('config user.name "Test"'); + writeFileSync(join(TMP, 'README.md'), '# test\n'); git('add .'); git('commit -m "init"'); +} + +describe('WorktreeBackend', () => { + const squadDir = () => join(TMP, '.squad'); + beforeEach(() => { if (existsSync(TMP)) rmSync(TMP, { recursive: true, force: true }); mkdirSync(squadDir(), { recursive: true }); }); + afterEach(() => { if (existsSync(TMP)) rmSync(TMP, { recursive: true, force: true }); }); + it('read/write/exists round-trip', () => { + const b = new WorktreeBackend(squadDir()); + expect(b.exists('team.md')).toBe(false); expect(b.read('team.md')).toBeUndefined(); + b.write('team.md', '# Team\n'); expect(b.exists('team.md')).toBe(true); expect(b.read('team.md')).toBe('# Team\n'); + }); + it('list returns directory entries', () => { + const b = new WorktreeBackend(squadDir()); + b.write('agents/data.md', '# Data'); b.write('agents/picard.md', '# Picard'); + expect(b.list('agents')).toContain('data.md'); expect(b.list('agents')).toContain('picard.md'); + }); + it('list returns empty for non-existent directory', () => { expect(new WorktreeBackend(squadDir()).list('nonexistent')).toEqual([]); }); + it('name is worktree', () => { expect(new WorktreeBackend(squadDir()).name).toBe('worktree'); }); +}); + +describe('GitNotesBackend', () => { + beforeEach(() => { if (existsSync(TMP)) rmSync(TMP, { recursive: true, force: true }); initRepo(); }); + afterEach(() => { if (existsSync(TMP)) rmSync(TMP, { recursive: true, force: true }); }); + it('read returns undefined when no note exists', () => { expect(new GitNotesBackend(TMP).read('team.md')).toBeUndefined(); }); + it('write then read round-trip', () => { const b = new GitNotesBackend(TMP); b.write('team.md', '# Team Config'); expect(b.read('team.md')).toBe('# Team Config'); }); + it('exists reflects write state', () => { const b = new GitNotesBackend(TMP); expect(b.exists('d/i/t.md')).toBe(false); b.write('d/i/t.md', 'x'); expect(b.exists('d/i/t.md')).toBe(true); }); + it('list returns entries in a virtual directory', () => { + const b = new GitNotesBackend(TMP); b.write('agents/data.md', 'D'); b.write('agents/picard.md', 'P'); b.write('agents/sub/n.md', 'N'); + const e = b.list('agents'); expect(e).toContain('data.md'); expect(e).toContain('picard.md'); expect(e).toContain('sub'); + }); + it('multiple writes update the same key', () => { const b = new GitNotesBackend(TMP); b.write('c.json', '1'); expect(b.read('c.json')).toBe('1'); b.write('c.json', '2'); expect(b.read('c.json')).toBe('2'); }); + it('normalizes Windows paths', () => { const b = new GitNotesBackend(TMP); b.write('agents\\data.md', 'D'); expect(b.read('agents/data.md')).toBe('D'); }); + it('name is git-notes', () => { expect(new GitNotesBackend(TMP).name).toBe('git-notes'); }); +}); + +describe('OrphanBranchBackend', () => { + beforeEach(() => { if (existsSync(TMP)) rmSync(TMP, { recursive: true, force: true }); initRepo(); }); + afterEach(() => { if (existsSync(TMP)) rmSync(TMP, { recursive: true, force: true }); }); + it('read returns undefined when branch does not exist', () => { expect(new OrphanBranchBackend(TMP).read('team.md')).toBeUndefined(); }); + it('write creates orphan branch', { timeout: 15_000 }, () => { + const b = new OrphanBranchBackend(TMP); b.write('team.md', '# Team'); expect(b.read('team.md')).toBe('# Team'); + expect(git('branch')).toContain('squad-state'); + let common = true; try { git('merge-base HEAD squad-state'); } catch { common = false; } expect(common).toBe(false); + }); + it('exists reflects write state', { timeout: 10_000 }, () => { const b = new OrphanBranchBackend(TMP); expect(b.exists('c.json')).toBe(false); b.write('c.json', '{}'); expect(b.exists('c.json')).toBe(true); }); + it('write to nested path', { timeout: 10_000 }, () => { const b = new OrphanBranchBackend(TMP); b.write('d/i/x.md', 'D'); expect(b.read('d/i/x.md')).toBe('D'); }); + it('list returns entries', { timeout: 15_000 }, () => { const b = new OrphanBranchBackend(TMP); b.write('agents/data.md', 'D'); b.write('agents/picard.md', 'P'); const e = b.list('agents'); expect(e).toContain('data.md'); expect(e).toContain('picard.md'); }); + it('list returns empty for non-existent path', () => { expect(new OrphanBranchBackend(TMP).list('nonexistent')).toEqual([]); }); + it('multiple writes preserve entries', { timeout: 15_000 }, () => { const b = new OrphanBranchBackend(TMP); b.write('a.md', 'first'); b.write('b.md', 'second'); expect(b.read('a.md')).toBe('first'); expect(b.read('b.md')).toBe('second'); }); + it('update existing file', { timeout: 15_000 }, () => { const b = new OrphanBranchBackend(TMP); b.write('t.md', 'v1'); b.write('t.md', 'v2'); expect(b.read('t.md')).toBe('v2'); }); + it('does not disturb working tree', { timeout: 10_000 }, () => { + const b = new OrphanBranchBackend(TMP); const before = readFileSync(join(TMP, 'README.md'), 'utf-8'); + b.write('s.json', '{}'); expect(readFileSync(join(TMP, 'README.md'), 'utf-8')).toBe(before); expect(git('status --porcelain')).toBe(''); + }); + it('name is orphan', () => { expect(new OrphanBranchBackend(TMP).name).toBe('orphan'); }); +}); + +describe('resolveStateBackend()', () => { + const squadDir = () => join(TMP, '.squad'); + beforeEach(() => { if (existsSync(TMP)) rmSync(TMP, { recursive: true, force: true }); initRepo(); mkdirSync(squadDir(), { recursive: true }); }); + afterEach(() => { if (existsSync(TMP)) rmSync(TMP, { recursive: true, force: true }); }); + it('defaults to worktree', () => { expect(resolveStateBackend(squadDir(), TMP).name).toBe('worktree'); }); + it('reads stateBackend from config.json', () => { + writeFileSync(join(squadDir(), 'config.json'), JSON.stringify({ version: 1, teamRoot: '.', stateBackend: 'git-notes' })); + expect(resolveStateBackend(squadDir(), TMP).name).toBe('git-notes'); + }); + it('CLI override wins over config', () => { + writeFileSync(join(squadDir(), 'config.json'), JSON.stringify({ version: 1, teamRoot: '.', stateBackend: 'git-notes' })); + expect(resolveStateBackend(squadDir(), TMP, 'orphan').name).toBe('orphan'); + }); + it('falls back on invalid type', () => { + writeFileSync(join(squadDir(), 'config.json'), JSON.stringify({ version: 1, teamRoot: '.', stateBackend: 'bad' })); + expect(resolveStateBackend(squadDir(), TMP).name).toBe('worktree'); + }); + it('falls back on malformed JSON', () => { writeFileSync(join(squadDir(), 'config.json'), 'bad'); expect(resolveStateBackend(squadDir(), TMP).name).toBe('worktree'); }); + it('external returns worktree stub', () => { expect(resolveStateBackend(squadDir(), TMP, 'external').name).toBe('worktree'); }); + it('all valid types accepted', () => { + for (const t of ['worktree', 'external', 'git-notes', 'orphan'] as const) expect(resolveStateBackend(squadDir(), TMP, t)).toBeDefined(); + }); +}); \ No newline at end of file