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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 79 additions & 0 deletions packages/squad-sdk/src/state/filesystem-backend.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
/**
* FilesystemBackend — Store Squad state in a directory on disk.
*
* This is the current default behavior — state lives in .squad/ or
* an external directory. Used as the fallback when git operations
* aren't available (non-git repos, contributor mode).
*/

import { readFile, writeFile, mkdir, readdir, unlink, access } from 'node:fs/promises';
import { join, dirname } from 'node:path';
import type { StateBackend, StateBackendHealth } from './state-backend.js';

export class FilesystemBackend implements StateBackend {
readonly name = 'filesystem';
private readonly root: string;

constructor(root: string) {
this.root = root;
}

async read(path: string): Promise<string | null> {
try {
return await readFile(join(this.root, path), 'utf-8');
} catch {
return null;
}
}

async write(path: string, content: string): Promise<void> {
const fullPath = join(this.root, path);
await mkdir(dirname(fullPath), { recursive: true });
await writeFile(fullPath, content, 'utf-8');
}

async exists(path: string): Promise<boolean> {
try {
await access(join(this.root, path));
return true;
} catch {
return false;
}
}

async list(dir: string): Promise<string[]> {
try {
return await readdir(join(this.root, dir));
} catch {
return [];
}
}

async remove(path: string): Promise<void> {
try {
await unlink(join(this.root, path));
} catch {
// Ignore if file doesn't exist
}
}

async doctor(): Promise<StateBackendHealth> {
try {
await access(this.root);
const entries = await this.list('.');
return {
healthy: true,
backend: this.name,
message: `State directory exists (${entries.length} entries)`,
details: { root: this.root, entryCount: String(entries.length) },
};
} catch {
return {
healthy: false,
backend: this.name,
message: `State directory not accessible: ${this.root}`,
details: { root: this.root },
};
}
}
}
11 changes: 11 additions & 0 deletions packages/squad-sdk/src/state/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
/**
* State module — Squad state persistence abstraction.
*
* Provides the StateBackend interface and implementations:
* - OrphanBranchBackend: State in a git orphan branch (immune to branch switches)
* - FilesystemBackend: State on disk (current default, fallback)
*/

export type { StateBackend, StateBackendHealth } from './state-backend.js';
export { OrphanBranchBackend } from './orphan-branch-backend.js';
export { FilesystemBackend } from './filesystem-backend.js';
261 changes: 261 additions & 0 deletions packages/squad-sdk/src/state/orphan-branch-backend.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,261 @@
/**
* OrphanBranchBackend — Store Squad state in a git orphan branch.
*
* Uses an orphan branch (default: `squad-state`) that has no parent
* commits and is completely independent of main/dev. State files are
* read/written via `git show` and `git commit-tree` + `git update-ref`,
* so they never appear in the working tree and survive all branch
* switches, rebases, and stashes.
*
* This is the core of the git-notes state solution proposed in:
* https://tamirdresher.com/blog/2026/03/23/scaling-ai-part7b-git-notes
*/

import { execFileSync } from 'node:child_process';
import type { StateBackend, StateBackendHealth } from './state-backend.js';

const DEFAULT_BRANCH = 'squad-state';
const DEFAULT_TIMEOUT = 10_000;

export class OrphanBranchBackend implements StateBackend {
readonly name = 'orphan-branch';
private readonly branch: string;
private readonly repoRoot: string;

constructor(repoRoot: string, branch = DEFAULT_BRANCH) {
this.repoRoot = repoRoot;
this.branch = branch;
}

/**
* Initialize the orphan branch if it doesn't exist.
* Safe to call multiple times — no-op if branch already exists.
*/
async init(): Promise<void> {
if (this.branchExists()) return;

// Create an empty orphan branch with an initial commit
// Use git mktree with empty stdin — portable across Windows/macOS/Linux
let emptyTree: string;
try {
emptyTree = this.git(['mktree'], '').trim();
} catch {
// Fallback: the well-known empty tree hash
emptyTree = '4b825dc642cb6eb9a060e54bf899d15363ed7564';
}
const commitHash = this.git(
['commit-tree', emptyTree, '-m', 'Initialize squad-state branch']
).trim();
this.git(['update-ref', `refs/heads/${this.branch}`, commitHash]);
}
Comment on lines +37 to +50
Copy link

Copilot AI Mar 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

init() tries to compute an empty tree via git hash-object -t tree /dev/null and then fall back with || ..., but this.git() throws on failure so the fallback is never reached (and /dev/null won’t exist on Windows). Use a try/catch around the hash-object call or just use git mktree with empty stdin for a portable empty tree.

Copilot uses AI. Check for mistakes.

async read(path: string): Promise<string | null> {
try {
return this.git(['show', `${this.branch}:${path}`]);
} catch {
return null;
}
}

async write(path: string, content: string): Promise<void> {
// Write content to a blob
const blobHash = this.git(['hash-object', '-w', '--stdin'], content).trim();

// Get the current tree (or empty tree if branch is fresh)
let baseTree: string;
try {
baseTree = this.git(['rev-parse', `${this.branch}^{tree}`]).trim();
} catch {
baseTree = this.git(['mktree'], '').trim();
}

// Build the new tree with the updated file
const treeContent = this.buildTreeWithFile(baseTree, path, blobHash);
const newTree = this.git(['mktree'], treeContent).trim();

// Create a commit pointing to the new tree
const parentHash = this.getHeadCommit();
const commitArgs = ['commit-tree', newTree, '-m', `Update ${path}`];
if (parentHash) {
commitArgs.push('-p', parentHash);
}
const newCommit = this.git(commitArgs).trim();

// Update the branch ref
this.git(['update-ref', `refs/heads/${this.branch}`, newCommit]);
}

async exists(path: string): Promise<boolean> {
try {
this.git(['cat-file', '-e', `${this.branch}:${path}`]);
return true;
} catch {
return false;
}
}

async list(dir: string): Promise<string[]> {
try {
// For root, list top-level entries; for subdirs, list that subtree
const ref = (dir === '.' || dir === '')
? this.branch
: `${this.branch}:${dir}`;
const output = this.git(['ls-tree', '--name-only', ref]);
if (!output.trim()) return [];
return output.split('\n').filter(Boolean);
} catch {
return [];
}
}
Comment on lines +97 to +109
Copy link

Copilot AI Mar 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

list(dir) currently runs git ls-tree --name-only <branch> which only returns top-level entries. That means list('agents') will return [] even if agents/fido/charter.md exists (unlike FilesystemBackend.list, which lists the directory’s contents). Consider using git ls-tree --name-only <branch>:<dir> for non-root directories (and <branch> for root) so dir works as intended.

Copilot uses AI. Check for mistakes.

async remove(path: string): Promise<void> {
// Use ls-tree to get all entries except the one we're removing
const entries = this.git(['ls-tree', '-r', this.branch])
.split('\n')
.filter(Boolean)
.filter(line => {
const filePath = line.split('\t')[1];
return filePath !== path;
})
.join('\n');

const newTree = this.git(['mktree'], entries).trim();
const parentHash = this.getHeadCommit();
const commitArgs = ['commit-tree', newTree, '-m', `Remove ${path}`];
if (parentHash) {
commitArgs.push('-p', parentHash);
}
const newCommit = this.git(commitArgs).trim();
this.git(['update-ref', `refs/heads/${this.branch}`, newCommit]);
}

async doctor(): Promise<StateBackendHealth> {
// Check 1: Is this a git repo?
try {
this.git(['rev-parse', '--git-dir']);
} catch {
return {
healthy: false,
backend: this.name,
message: 'Not a git repository',
};
}

// Check 2: Does the orphan branch exist?
if (!this.branchExists()) {
return {
healthy: false,
backend: this.name,
message: `Orphan branch '${this.branch}' does not exist. Run squad init to create it.`,
details: { branch: this.branch },
};
}

// Check 3: Can we read from it?
try {
this.git(['ls-tree', '--name-only', this.branch]);
} catch (err) {
return {
healthy: false,
backend: this.name,
message: `Cannot read from orphan branch '${this.branch}'`,
details: { error: String(err) },
};
}

// Check 4: Count state files
const files = await this.list('.');

return {
healthy: true,
backend: this.name,
message: `Orphan branch '${this.branch}' is healthy (${files.length} top-level entries)`,
details: {
branch: this.branch,
fileCount: String(files.length),
},
};
}

// ============================================================================
// Private helpers
// ============================================================================

private branchExists(): boolean {
try {
this.git(['rev-parse', '--verify', `refs/heads/${this.branch}`]);
return true;
} catch {
return false;
}
}

private getHeadCommit(): string | null {
try {
return this.git(['rev-parse', this.branch]).trim();
} catch {
return null;
}
}

/**
* Build a new tree that includes all existing entries plus the new file.
* Handles nested paths by creating subtrees as needed.
*/
private buildTreeWithFile(baseTree: string, filePath: string, blobHash: string): string {
// Get existing tree entries
let entries: string[];
try {
entries = this.git(['ls-tree', baseTree])
.split('\n')
.filter(Boolean);
} catch {
entries = [];
}

// For simple (non-nested) paths, add/replace the entry
if (!filePath.includes('/')) {
const filtered = entries.filter(e => !e.endsWith(`\t${filePath}`));
filtered.push(`100644 blob ${blobHash}\t${filePath}`);
return filtered.join('\n');
}

// For nested paths, we need to handle subtrees
const parts = filePath.split('/');
const dirName = parts[0];
const restPath = parts.slice(1).join('/');

// Find or create the subtree for this directory
let subtreeHash: string;
const existingEntry = entries.find(e => e.endsWith(`\t${dirName}`) && e.includes(' tree '));
if (existingEntry) {
subtreeHash = existingEntry.split(/\s+/)[2];
} else {
subtreeHash = this.git(['mktree'], '').trim();
}

// Recursively build the subtree
const subtreeContent = this.buildTreeWithFile(subtreeHash, restPath, blobHash);
const newSubtreeHash = this.git(['mktree'], subtreeContent).trim();

// Replace the subtree entry
const filtered = entries.filter(e => !e.endsWith(`\t${dirName}`));
filtered.push(`040000 tree ${newSubtreeHash}\t${dirName}`);
return filtered.join('\n');
}

private git(args: string[], input?: string): string {
try {
return execFileSync('git', args, {
cwd: this.repoRoot,
encoding: 'utf-8',
timeout: DEFAULT_TIMEOUT,
input,
stdio: input !== undefined ? ['pipe', 'pipe', 'pipe'] : ['ignore', 'pipe', 'pipe'],
});
} catch (err: unknown) {
const error = err as { stderr?: string; message?: string };
throw new Error(`git ${args[0]} failed: ${error.stderr || error.message}`);
}
}
}
41 changes: 41 additions & 0 deletions packages/squad-sdk/src/state/state-backend.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
/**
* StateBackend — Interface for Squad state persistence.
*
* Squad state (.squad/) can live in different backends:
* - Working tree (current default — fragile, destroyed by branch switches)
* - Orphan branch (immune to branch switches — this POC)
* - External directory (~/.squad/projects/ — for contributor mode)
*
* This interface abstracts the read/write operations so the rest of
* Squad doesn't need to know where state lives.
*/

export interface StateBackend {
/** Human-readable name for diagnostics (e.g., "orphan-branch", "filesystem") */
readonly name: string;

/** Read a file from state. Returns null if not found. */
read(path: string): Promise<string | null>;

/** Write a file to state. Creates parent directories as needed. */
write(path: string, content: string): Promise<void>;

/** Check if a file exists in state. */
exists(path: string): Promise<boolean>;

/** List files in a directory within state. Returns relative paths. */
list(dir: string): Promise<string[]>;

/** Delete a file from state. */
remove(path: string): Promise<void>;

/** Validate that the backend is healthy and accessible. */
doctor(): Promise<StateBackendHealth>;
}

export interface StateBackendHealth {
healthy: boolean;
backend: string;
message: string;
details?: Record<string, string>;
}
Loading
Loading