fix(state-backend): conditional .gitignore for two-layer/orphan state files#1229
fix(state-backend): conditional .gitignore for two-layer/orphan state files#1229tamirdresher wants to merge 1 commit into
Conversation
…an backends Adds .squad/decisions.md and .squad/agents/*/history.md to .gitignore (within a marker-delimited block) when stateBackend is 'two-layer' or 'orphan'. Removes the block when switching back to 'local'. Defense-in-depth complement to the pre-commit hook installed by squad upgrade --state-backend. - New helper module: packages/squad-sdk/src/config/gitignore-state.ts with addSquadStateGitignoreBlock + removeSquadStateGitignoreBlock - initSquad calls helpers based on options.stateBackend (adds stateBackend to InitOptions in SDK) - CLI runInit passes stateBackend to sdkInitSquad - migrate-backend calls helpers on backend transitions - Unit tests for helpers (add/remove idempotency, round-trip) - Integration tests for init and migration paths Closes #1228 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
🛫 PR Readiness Check
PR Scope: 📦🔧 Mixed (product + infrastructure)
|
| Status | Check | Details |
|---|---|---|
| ✅ | Single commit | 1 commit — clean history |
| ✅ | Not in draft | Ready for review |
| ✅ | Branch up to date | Up to date with dev |
| ❌ | Copilot review | No Copilot review yet — it may still be processing |
| ✅ | Changeset present | Changeset file found |
| ✅ | Scope clean | No .squad/ or docs/proposals/ files |
| ✅ | No merge conflicts | No merge conflicts |
| ✅ | Copilot threads resolved | No Copilot review threads |
| ❌ | CI passing | 7 check(s) still running |
Files Changed (9 files, +505 −1)
| File | +/− |
|---|---|
.changeset/conditional-state-gitignore.md |
+10 −0 |
packages/squad-cli/src/cli/commands/migrate-backend.ts |
+23 −0 |
packages/squad-cli/src/cli/core/init.ts |
+1 −0 |
packages/squad-sdk/src/config/gitignore-state.ts |
+95 −0 |
packages/squad-sdk/src/config/index.ts |
+1 −0 |
packages/squad-sdk/src/config/init.ts |
+20 −1 |
test/gitignore-state.test.ts |
+178 −0 |
test/init-scaffolding.test.ts |
+70 −0 |
test/upgrade-state-backend.test.ts |
+107 −0 |
Total: +505 −1
This check runs automatically on every push. Fix any ❌ items and push again.
See CONTRIBUTING.md and PR Requirements for details.
🟡 Impact Analysis — PR #1229Risk tier: 🟡 MEDIUM 📊 Summary
🎯 Risk Factors
📦 Modules Affectedroot (1 file)
squad-cli (2 files)
squad-sdk (3 files)
tests (3 files)
|
🏗️ Architectural Review
Automated architectural review — informational only. |
There was a problem hiding this comment.
Pull request overview
This PR adds defense-in-depth behavior for the two-layer and orphan state backends by automatically managing a marker-delimited block in .gitignore to prevent accidental staging of squad-state-owned files (.squad/decisions.md and .squad/agents/*/history.md) during init and backend migration.
Changes:
- Added SDK helpers to add/remove a marker-delimited
.gitignoreblock for squad-state-owned files. - Wired the helper into SDK
initSquad()and CLI backend migration so the block is added/removed on relevant backend transitions. - Added unit + integration tests covering add/remove/idempotency and init/upgrade flows.
Reviewed changes
Copilot reviewed 9 out of 9 changed files in this pull request and generated 6 comments.
Show a summary per file
| File | Description |
|---|---|
packages/squad-sdk/src/config/gitignore-state.ts |
Introduces add/remove helpers and marker constants for managing the .gitignore block. |
packages/squad-sdk/src/config/init.ts |
Extends InitOptions with stateBackend and applies the helper during init based on backend choice. |
packages/squad-sdk/src/config/index.ts |
Exports the new gitignore-state helper module from the SDK config barrel. |
packages/squad-cli/src/cli/core/init.ts |
Passes stateBackend through from CLI init to the SDK initializer. |
packages/squad-cli/src/cli/commands/migrate-backend.ts |
Adds logic to update .gitignore marker block when migrating between local/worktree and orphan/two-layer backends. |
test/gitignore-state.test.ts |
New unit tests for idempotent add/remove and round-trip behavior using in-memory storage. |
test/init-scaffolding.test.ts |
Integration tests for initSquad() writing (or not writing) the marker block depending on stateBackend. |
test/upgrade-state-backend.test.ts |
Integration tests for backend migration adding/removing/not-duplicating the .gitignore marker block. |
.changeset/conditional-state-gitignore.md |
Adds a patch changeset for both SDK and CLI to ship the behavior. |
| * When the state backend is 'two-layer' or 'orphan', .squad/decisions.md | ||
| * and .squad/agents/history.md are owned by the squad-state orphan branch. | ||
| * Adding them to .gitignore prevents accidental staging via git add ., |
| * Finds the opening and closing marker lines and removes all lines between | ||
| * them (inclusive), plus one leading blank line in what follows if present. | ||
| * Returns true if the block was removed, false if it was not present. |
| * When 'orphan' or 'two-layer', adds a marker-delimited block to .gitignore | ||
| * so .squad/decisions.md and .squad/agents/*\/history.md are not accidentally | ||
| * staged into the working-tree commit graph. |
| it('.gitignore round-trip: local→two-layer→local leaves file byte-identical', { timeout: 30_000 }, async () => { | ||
| dir = mkRepo('local'); | ||
| const gitignorePath = path.join(dir, '.gitignore'); | ||
| const existingContent = '# my project\nnode_modules/\ndist/\n'; | ||
| fs.writeFileSync(gitignorePath, existingContent); | ||
|
|
||
| await migrateStateBackend(dir, 'two-layer'); | ||
| // Migrate back to local but this time need to create fresh repo with same config | ||
| // We need to start a new repo already set to two-layer then go back | ||
| const twoLayerDir = mkRepo('two-layer'); | ||
| const twoLayerGitignorePath = path.join(twoLayerDir, '.gitignore'); | ||
| fs.writeFileSync(twoLayerGitignorePath, existingContent); | ||
| await migrateStateBackend(twoLayerDir, 'local'); | ||
| // After round-trip: the marker block should not be present | ||
| const finalContent = fs.readFileSync(twoLayerGitignorePath, 'utf-8'); | ||
| expect(finalContent).not.toContain('# Squad: state owned by squad-state branch'); | ||
| try { fs.rmSync(twoLayerDir, { recursive: true, force: true }); } catch { /* best-effort */ } |
| const content = storage.readSync(gitignorePath) ?? ''; | ||
| if (!content.includes(SQUAD_STATE_GITIGNORE_OPEN_MARKER)) { | ||
| return false; | ||
| } | ||
|
|
||
| const lines = content.split('\n'); | ||
| const openIdx = lines.findIndex((l) => l === SQUAD_STATE_GITIGNORE_OPEN_MARKER); | ||
| if (openIdx === -1) return false; | ||
|
|
||
| // Find the closing marker starting from the opening marker | ||
| let closeIdx = -1; | ||
| for (let i = openIdx + 1; i < lines.length; i++) { | ||
| if (lines[i] === SQUAD_STATE_GITIGNORE_CLOSE_MARKER) { | ||
| closeIdx = i; | ||
| break; | ||
| } | ||
| } |
| // Conditionally add/remove squad-state .gitignore block | ||
| // When stateBackend is 'orphan' or 'two-layer', .squad/decisions.md and | ||
| // .squad/agents/*/history.md are owned by the squad-state branch — add a | ||
| // marker-delimited block so `git add .` cannot accidentally stage them. | ||
| // When stateBackend is 'local' (or undefined), remove the block if present. | ||
| // ------------------------------------------------------------------------- | ||
|
|
||
| if (options.stateBackend === 'orphan' || options.stateBackend === 'two-layer') { | ||
| addSquadStateGitignoreBlock(gitignorePath, storage); | ||
| } else { | ||
| removeSquadStateGitignoreBlock(gitignorePath, storage); | ||
| } |
Closes #1228
Summary
Defense-in-depth complement to the pre-commit hook installed by
squad upgrade --state-backend two-layer|orphan. Adds.squad/decisions.mdand.squad/agents/*/history.mdto.gitignore(in a marker-delimited block) when those backends are active, and removes the block when switching back tolocal.Result:
git add .,git add -A, IDE "stage all", andgit commit -amwill no longer stage two-layer state into the working tree. The pre-commit hook stays as a hard backstop forgit add --forceand edge cases.What changed
packages/squad-sdk/src/config/gitignore-state.ts--addSquadStateGitignoreBlock()andremoveSquadStateGitignoreBlock()with marker-delimited block (# Squad: state owned by squad-state branchto# /Squad: ...)packages/squad-sdk/src/config/init.ts-- addsstateBackendtoInitOptions; calls helpers based onoptions.stateBackendpackages/squad-cli/src/cli/core/init.ts-- passesstateBackendtosdkInitSquad()packages/squad-cli/src/cli/commands/migrate-backend.ts-- calls helpers on backend transitions (local to orphan/two-layer adds; orphan/two-layer to local removes)test/gitignore-state.test.ts-- 14 tests covering idempotent add, idempotent remove, round-trip byte equality, content preservationtest/init-scaffolding.test.tsandtest/upgrade-state-backend.test.ts.changeset/conditional-state-gitignore.md(patch bump for both SDK and CLI)State transitions tested
Test plan
Out of scope