Merge pull request #239 from julianknutsen/fix/claude-settings-inheritance

fix: Isolate Claude settings outside source repos, unify agent startup
This commit is contained in:
Steve Yegge
2026-01-07 00:10:05 -08:00
committed by GitHub
41 changed files with 12381 additions and 1035 deletions

View File

@@ -4,11 +4,25 @@ import (
"encoding/json"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"github.com/steveyegge/gastown/internal/claude"
"github.com/steveyegge/gastown/internal/session"
"github.com/steveyegge/gastown/internal/templates"
"github.com/steveyegge/gastown/internal/tmux"
"github.com/steveyegge/gastown/internal/workspace"
)
// gitFileStatus represents the git status of a file.
type gitFileStatus string
const (
gitStatusUntracked gitFileStatus = "untracked" // File not tracked by git
gitStatusTrackedClean gitFileStatus = "tracked-clean" // Tracked, no local modifications
gitStatusTrackedModified gitFileStatus = "tracked-modified" // Tracked with local modifications
gitStatusUnknown gitFileStatus = "unknown" // Not in a git repo or error
)
// ClaudeSettingsCheck verifies that Claude settings.json files match the expected templates.
@@ -19,12 +33,13 @@ type ClaudeSettingsCheck struct {
}
type staleSettingsInfo struct {
path string // Full path to settings.json
agentType string // e.g., "witness", "refinery", "deacon", "mayor"
rigName string // Rig name (empty for town-level agents)
sessionName string // tmux session name for cycling
missing []string // What's missing from the settings
wrongLocation bool // True if file is in wrong location (should be deleted)
path string // Full path to settings.json
agentType string // e.g., "witness", "refinery", "deacon", "mayor"
rigName string // Rig name (empty for town-level agents)
sessionName string // tmux session name for cycling
missing []string // What's missing from the settings
wrongLocation bool // True if file is in wrong location (should be deleted)
gitStatus gitFileStatus // Git status for wrong-location files (for safe deletion)
}
// NewClaudeSettingsCheck creates a new Claude settings validation check.
@@ -44,6 +59,7 @@ func (c *ClaudeSettingsCheck) Run(ctx *CheckContext) *CheckResult {
c.staleSettings = nil
var details []string
var hasModifiedFiles bool
// Find all settings.json files
settingsFiles := c.findSettingsFiles(ctx.TownRoot)
@@ -51,8 +67,24 @@ func (c *ClaudeSettingsCheck) Run(ctx *CheckContext) *CheckResult {
for _, sf := range settingsFiles {
// Files in wrong locations are always stale (should be deleted)
if sf.wrongLocation {
// Check git status to determine safe deletion strategy
sf.gitStatus = c.getGitFileStatus(sf.path)
c.staleSettings = append(c.staleSettings, sf)
details = append(details, fmt.Sprintf("%s: wrong location (should be in rig/ subdirectory)", sf.path))
// Provide detailed message based on git status
var statusMsg string
switch sf.gitStatus {
case gitStatusUntracked:
statusMsg = "wrong location, untracked (safe to delete)"
case gitStatusTrackedClean:
statusMsg = "wrong location, tracked but unmodified (safe to delete)"
case gitStatusTrackedModified:
statusMsg = "wrong location, tracked with local modifications (manual review needed)"
hasModifiedFiles = true
default:
statusMsg = "wrong location (inside source repo)"
}
details = append(details, fmt.Sprintf("%s: %s", sf.path, statusMsg))
continue
}
@@ -73,12 +105,17 @@ func (c *ClaudeSettingsCheck) Run(ctx *CheckContext) *CheckResult {
}
}
fixHint := "Run 'gt doctor --fix' to update settings and restart affected agents"
if hasModifiedFiles {
fixHint = "Run 'gt doctor --fix' to fix safe issues. Files with local modifications require manual review."
}
return &CheckResult{
Name: c.Name(),
Status: StatusError,
Message: fmt.Sprintf("Found %d stale Claude settings.json file(s)", len(c.staleSettings)),
Message: fmt.Sprintf("Found %d stale Claude config file(s) in wrong location", len(c.staleSettings)),
Details: details,
FixHint: "Run 'gt doctor --fix' to update settings and restart affected agents",
FixHint: fixHint,
}
}
@@ -86,13 +123,44 @@ func (c *ClaudeSettingsCheck) Run(ctx *CheckContext) *CheckResult {
func (c *ClaudeSettingsCheck) findSettingsFiles(townRoot string) []staleSettingsInfo {
var files []staleSettingsInfo
// Town-level: mayor (~/gt/.claude/settings.json)
mayorSettings := filepath.Join(townRoot, ".claude", "settings.json")
// Check for STALE settings at town root (~/gt/.claude/settings.json)
// This is WRONG - settings here pollute ALL child workspaces via directory traversal.
// Mayor settings should be at ~/gt/mayor/.claude/ instead.
staleTownRootSettings := filepath.Join(townRoot, ".claude", "settings.json")
if fileExists(staleTownRootSettings) {
files = append(files, staleSettingsInfo{
path: staleTownRootSettings,
agentType: "mayor",
sessionName: "hq-mayor",
wrongLocation: true,
gitStatus: c.getGitFileStatus(staleTownRootSettings),
missing: []string{"should be at mayor/.claude/settings.json, not town root"},
})
}
// Check for STALE CLAUDE.md at town root (~/gt/CLAUDE.md)
// This is WRONG - CLAUDE.md here is inherited by ALL agents via directory traversal,
// causing crew/polecat/etc to receive Mayor-specific instructions.
// Mayor's CLAUDE.md should be at ~/gt/mayor/CLAUDE.md instead.
staleTownRootCLAUDEmd := filepath.Join(townRoot, "CLAUDE.md")
if fileExists(staleTownRootCLAUDEmd) {
files = append(files, staleSettingsInfo{
path: staleTownRootCLAUDEmd,
agentType: "mayor",
sessionName: "hq-mayor",
wrongLocation: true,
gitStatus: c.getGitFileStatus(staleTownRootCLAUDEmd),
missing: []string{"should be at mayor/CLAUDE.md, not town root"},
})
}
// Town-level: mayor (~/gt/mayor/.claude/settings.json) - CORRECT location
mayorSettings := filepath.Join(townRoot, "mayor", ".claude", "settings.json")
if fileExists(mayorSettings) {
files = append(files, staleSettingsInfo{
path: mayorSettings,
agentType: "mayor",
sessionName: "gt-mayor",
sessionName: "hq-mayor",
})
}
@@ -102,7 +170,7 @@ func (c *ClaudeSettingsCheck) findSettingsFiles(townRoot string) []staleSettings
files = append(files, staleSettingsInfo{
path: deaconSettings,
agentType: "deacon",
sessionName: "gt-deacon",
sessionName: "hq-deacon",
})
}
@@ -126,18 +194,18 @@ func (c *ClaudeSettingsCheck) findSettingsFiles(townRoot string) []staleSettings
continue
}
// Check for witness settings - rig/ is correct location, without rig/ is wrong
witnessRigSettings := filepath.Join(rigPath, "witness", "rig", ".claude", "settings.json")
if fileExists(witnessRigSettings) {
// Check for witness settings - witness/.claude/ is correct (outside git repo)
// Settings in witness/rig/.claude/ are wrong (inside source repo)
witnessSettings := filepath.Join(rigPath, "witness", ".claude", "settings.json")
if fileExists(witnessSettings) {
files = append(files, staleSettingsInfo{
path: witnessRigSettings,
path: witnessSettings,
agentType: "witness",
rigName: rigName,
sessionName: fmt.Sprintf("gt-%s-witness", rigName),
})
}
// Settings in witness/.claude/ (not witness/rig/.claude/) are in wrong location
witnessWrongSettings := filepath.Join(rigPath, "witness", ".claude", "settings.json")
witnessWrongSettings := filepath.Join(rigPath, "witness", "rig", ".claude", "settings.json")
if fileExists(witnessWrongSettings) {
files = append(files, staleSettingsInfo{
path: witnessWrongSettings,
@@ -148,18 +216,18 @@ func (c *ClaudeSettingsCheck) findSettingsFiles(townRoot string) []staleSettings
})
}
// Check for refinery settings - rig/ is correct location, without rig/ is wrong
refineryRigSettings := filepath.Join(rigPath, "refinery", "rig", ".claude", "settings.json")
if fileExists(refineryRigSettings) {
// Check for refinery settings - refinery/.claude/ is correct (outside git repo)
// Settings in refinery/rig/.claude/ are wrong (inside source repo)
refinerySettings := filepath.Join(rigPath, "refinery", ".claude", "settings.json")
if fileExists(refinerySettings) {
files = append(files, staleSettingsInfo{
path: refineryRigSettings,
path: refinerySettings,
agentType: "refinery",
rigName: rigName,
sessionName: fmt.Sprintf("gt-%s-refinery", rigName),
})
}
// Settings in refinery/.claude/ (not refinery/rig/.claude/) are in wrong location
refineryWrongSettings := filepath.Join(rigPath, "refinery", ".claude", "settings.json")
refineryWrongSettings := filepath.Join(rigPath, "refinery", "rig", ".claude", "settings.json")
if fileExists(refineryWrongSettings) {
files = append(files, staleSettingsInfo{
path: refineryWrongSettings,
@@ -170,41 +238,63 @@ func (c *ClaudeSettingsCheck) findSettingsFiles(townRoot string) []staleSettings
})
}
// Check for crew settings (crew/<name>/.claude/)
// Check for crew settings - crew/.claude/ is correct (shared by all crew, outside git repos)
// Settings in crew/<name>/.claude/ are wrong (inside git repos)
crewDir := filepath.Join(rigPath, "crew")
crewSettings := filepath.Join(crewDir, ".claude", "settings.json")
if fileExists(crewSettings) {
files = append(files, staleSettingsInfo{
path: crewSettings,
agentType: "crew",
rigName: rigName,
sessionName: "", // Shared settings, no single session
})
}
if dirExists(crewDir) {
crewEntries, _ := os.ReadDir(crewDir)
for _, crewEntry := range crewEntries {
if !crewEntry.IsDir() {
if !crewEntry.IsDir() || crewEntry.Name() == ".claude" {
continue
}
crewSettings := filepath.Join(crewDir, crewEntry.Name(), ".claude", "settings.json")
if fileExists(crewSettings) {
crewWrongSettings := filepath.Join(crewDir, crewEntry.Name(), ".claude", "settings.json")
if fileExists(crewWrongSettings) {
files = append(files, staleSettingsInfo{
path: crewSettings,
agentType: "crew",
rigName: rigName,
sessionName: fmt.Sprintf("gt-%s-crew-%s", rigName, crewEntry.Name()),
path: crewWrongSettings,
agentType: "crew",
rigName: rigName,
sessionName: fmt.Sprintf("gt-%s-crew-%s", rigName, crewEntry.Name()),
wrongLocation: true,
})
}
}
}
// Check for polecat settings (polecats/<name>/.claude/)
// Check for polecat settings - polecats/.claude/ is correct (shared by all polecats, outside git repos)
// Settings in polecats/<name>/.claude/ are wrong (inside git repos)
polecatsDir := filepath.Join(rigPath, "polecats")
polecatsSettings := filepath.Join(polecatsDir, ".claude", "settings.json")
if fileExists(polecatsSettings) {
files = append(files, staleSettingsInfo{
path: polecatsSettings,
agentType: "polecat",
rigName: rigName,
sessionName: "", // Shared settings, no single session
})
}
if dirExists(polecatsDir) {
polecatEntries, _ := os.ReadDir(polecatsDir)
for _, pcEntry := range polecatEntries {
if !pcEntry.IsDir() {
if !pcEntry.IsDir() || pcEntry.Name() == ".claude" {
continue
}
pcSettings := filepath.Join(polecatsDir, pcEntry.Name(), ".claude", "settings.json")
if fileExists(pcSettings) {
pcWrongSettings := filepath.Join(polecatsDir, pcEntry.Name(), ".claude", "settings.json")
if fileExists(pcWrongSettings) {
files = append(files, staleSettingsInfo{
path: pcSettings,
agentType: "polecat",
rigName: rigName,
sessionName: fmt.Sprintf("gt-%s-polecat-%s", rigName, pcEntry.Name()),
path: pcWrongSettings,
agentType: "polecat",
rigName: rigName,
sessionName: fmt.Sprintf("gt-%s-%s", rigName, pcEntry.Name()),
wrongLocation: true,
})
}
}
@@ -267,6 +357,46 @@ func (c *ClaudeSettingsCheck) checkSettings(path, _ string) []string {
return missing
}
// getGitFileStatus determines the git status of a file.
// Returns untracked, tracked-clean, tracked-modified, or unknown.
func (c *ClaudeSettingsCheck) getGitFileStatus(filePath string) gitFileStatus {
dir := filepath.Dir(filePath)
fileName := filepath.Base(filePath)
// Check if we're in a git repo
cmd := exec.Command("git", "-C", dir, "rev-parse", "--git-dir")
if err := cmd.Run(); err != nil {
return gitStatusUnknown
}
// Check if file is tracked
cmd = exec.Command("git", "-C", dir, "ls-files", fileName)
output, err := cmd.Output()
if err != nil {
return gitStatusUnknown
}
if len(strings.TrimSpace(string(output))) == 0 {
// File is not tracked
return gitStatusUntracked
}
// File is tracked - check if modified
cmd = exec.Command("git", "-C", dir, "diff", "--quiet", fileName)
if err := cmd.Run(); err != nil {
// Non-zero exit means file has changes
return gitStatusTrackedModified
}
// Also check for staged changes
cmd = exec.Command("git", "-C", dir, "diff", "--cached", "--quiet", fileName)
if err := cmd.Run(); err != nil {
return gitStatusTrackedModified
}
return gitStatusTrackedClean
}
// hookHasPattern checks if a hook contains a specific pattern.
func (c *ClaudeSettingsCheck) hookHasPattern(hooks map[string]any, hookName, pattern string) bool {
hookList, ok := hooks[hookName].([]any)
@@ -298,11 +428,19 @@ func (c *ClaudeSettingsCheck) hookHasPattern(hooks map[string]any, hookName, pat
}
// Fix deletes stale settings files and restarts affected agents.
// Files with local modifications are skipped to avoid losing user changes.
func (c *ClaudeSettingsCheck) Fix(ctx *CheckContext) error {
var errors []string
var skipped []string
t := tmux.NewTmux()
for _, sf := range c.staleSettings {
// Skip files with local modifications - require manual review
if sf.wrongLocation && sf.gitStatus == gitStatusTrackedModified {
skipped = append(skipped, fmt.Sprintf("%s: has local modifications, skipping", sf.path))
continue
}
// Delete the stale settings file
if err := os.Remove(sf.path); err != nil {
errors = append(errors, fmt.Sprintf("failed to delete %s: %v", sf.path, err))
@@ -313,9 +451,40 @@ func (c *ClaudeSettingsCheck) Fix(ctx *CheckContext) error {
claudeDir := filepath.Dir(sf.path)
_ = os.Remove(claudeDir) // Best-effort, will fail if not empty
// For files in wrong locations, just delete - don't recreate
// The correct location will get settings when the agent starts
// For files in wrong locations, delete and create at correct location
if sf.wrongLocation {
mayorDir := filepath.Join(ctx.TownRoot, "mayor")
// For mayor settings.json at town root, create at mayor/.claude/
if sf.agentType == "mayor" && strings.HasSuffix(claudeDir, ".claude") && !strings.Contains(sf.path, "/mayor/") {
if err := os.MkdirAll(mayorDir, 0755); err == nil {
_ = claude.EnsureSettingsForRole(mayorDir, "mayor")
}
}
// For mayor CLAUDE.md at town root, create at mayor/
if sf.agentType == "mayor" && strings.HasSuffix(sf.path, "CLAUDE.md") && !strings.Contains(sf.path, "/mayor/") {
townName, _ := workspace.GetTownName(ctx.TownRoot)
if err := templates.CreateMayorCLAUDEmd(
mayorDir,
ctx.TownRoot,
townName,
session.MayorSessionName(),
session.DeaconSessionName(),
); err != nil {
errors = append(errors, fmt.Sprintf("failed to create mayor/CLAUDE.md: %v", err))
}
}
// Town-root files were inherited by ALL agents via directory traversal.
// Cycle all Gas Town sessions so they pick up the corrected file locations.
// This includes gt-* (rig agents) and hq-* (mayor, deacon).
sessions, _ := t.ListSessions()
for _, sess := range sessions {
if strings.HasPrefix(sess, session.Prefix) || strings.HasPrefix(sess, session.HQPrefix) {
_ = t.KillSession(sess)
}
}
continue
}
@@ -338,6 +507,13 @@ func (c *ClaudeSettingsCheck) Fix(ctx *CheckContext) error {
}
}
// Report skipped files as warnings, not errors
if len(skipped) > 0 {
for _, s := range skipped {
fmt.Printf(" Warning: %s\n", s)
}
}
if len(errors) > 0 {
return fmt.Errorf("%s", strings.Join(errors, "; "))
}

View File

@@ -3,6 +3,7 @@ package doctor
import (
"encoding/json"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
@@ -178,8 +179,9 @@ func createStaleSettings(t *testing.T, path string, missingElements ...string) {
func TestClaudeSettingsCheck_ValidMayorSettings(t *testing.T) {
tmpDir := t.TempDir()
// Create valid mayor settings
mayorSettings := filepath.Join(tmpDir, ".claude", "settings.json")
// Create valid mayor settings at correct location (mayor/.claude/settings.json)
// NOT at town root (.claude/settings.json) which is wrong location
mayorSettings := filepath.Join(tmpDir, "mayor", ".claude", "settings.json")
createValidSettings(t, mayorSettings)
check := NewClaudeSettingsCheck()
@@ -213,8 +215,8 @@ func TestClaudeSettingsCheck_ValidWitnessSettings(t *testing.T) {
tmpDir := t.TempDir()
rigName := "testrig"
// Create valid witness settings in correct location (rig/.claude/)
witnessSettings := filepath.Join(tmpDir, rigName, "witness", "rig", ".claude", "settings.json")
// Create valid witness settings in correct location (witness/.claude/, outside git repo)
witnessSettings := filepath.Join(tmpDir, rigName, "witness", ".claude", "settings.json")
createValidSettings(t, witnessSettings)
check := NewClaudeSettingsCheck()
@@ -231,8 +233,8 @@ func TestClaudeSettingsCheck_ValidRefinerySettings(t *testing.T) {
tmpDir := t.TempDir()
rigName := "testrig"
// Create valid refinery settings in correct location
refinerySettings := filepath.Join(tmpDir, rigName, "refinery", "rig", ".claude", "settings.json")
// Create valid refinery settings in correct location (refinery/.claude/, outside git repo)
refinerySettings := filepath.Join(tmpDir, rigName, "refinery", ".claude", "settings.json")
createValidSettings(t, refinerySettings)
check := NewClaudeSettingsCheck()
@@ -249,8 +251,8 @@ func TestClaudeSettingsCheck_ValidCrewSettings(t *testing.T) {
tmpDir := t.TempDir()
rigName := "testrig"
// Create valid crew agent settings
crewSettings := filepath.Join(tmpDir, rigName, "crew", "agent1", ".claude", "settings.json")
// Create valid crew settings in correct location (crew/.claude/, shared by all crew)
crewSettings := filepath.Join(tmpDir, rigName, "crew", ".claude", "settings.json")
createValidSettings(t, crewSettings)
check := NewClaudeSettingsCheck()
@@ -267,8 +269,8 @@ func TestClaudeSettingsCheck_ValidPolecatSettings(t *testing.T) {
tmpDir := t.TempDir()
rigName := "testrig"
// Create valid polecat settings
pcSettings := filepath.Join(tmpDir, rigName, "polecats", "pc1", ".claude", "settings.json")
// Create valid polecat settings in correct location (polecats/.claude/, shared by all polecats)
pcSettings := filepath.Join(tmpDir, rigName, "polecats", ".claude", "settings.json")
createValidSettings(t, pcSettings)
check := NewClaudeSettingsCheck()
@@ -284,8 +286,8 @@ func TestClaudeSettingsCheck_ValidPolecatSettings(t *testing.T) {
func TestClaudeSettingsCheck_MissingEnabledPlugins(t *testing.T) {
tmpDir := t.TempDir()
// Create stale mayor settings missing enabledPlugins
mayorSettings := filepath.Join(tmpDir, ".claude", "settings.json")
// Create stale mayor settings missing enabledPlugins (at correct location)
mayorSettings := filepath.Join(tmpDir, "mayor", ".claude", "settings.json")
createStaleSettings(t, mayorSettings, "enabledPlugins")
check := NewClaudeSettingsCheck()
@@ -304,8 +306,8 @@ func TestClaudeSettingsCheck_MissingEnabledPlugins(t *testing.T) {
func TestClaudeSettingsCheck_MissingHooks(t *testing.T) {
tmpDir := t.TempDir()
// Create stale settings missing hooks entirely
mayorSettings := filepath.Join(tmpDir, ".claude", "settings.json")
// Create stale settings missing hooks entirely (at correct location)
mayorSettings := filepath.Join(tmpDir, "mayor", ".claude", "settings.json")
createStaleSettings(t, mayorSettings, "hooks")
check := NewClaudeSettingsCheck()
@@ -321,8 +323,8 @@ func TestClaudeSettingsCheck_MissingHooks(t *testing.T) {
func TestClaudeSettingsCheck_MissingPATH(t *testing.T) {
tmpDir := t.TempDir()
// Create stale settings missing PATH export
mayorSettings := filepath.Join(tmpDir, ".claude", "settings.json")
// Create stale settings missing PATH export (at correct location)
mayorSettings := filepath.Join(tmpDir, "mayor", ".claude", "settings.json")
createStaleSettings(t, mayorSettings, "PATH")
check := NewClaudeSettingsCheck()
@@ -348,8 +350,8 @@ func TestClaudeSettingsCheck_MissingPATH(t *testing.T) {
func TestClaudeSettingsCheck_MissingDeaconNudge(t *testing.T) {
tmpDir := t.TempDir()
// Create stale settings missing deacon nudge
mayorSettings := filepath.Join(tmpDir, ".claude", "settings.json")
// Create stale settings missing deacon nudge (at correct location)
mayorSettings := filepath.Join(tmpDir, "mayor", ".claude", "settings.json")
createStaleSettings(t, mayorSettings, "deacon-nudge")
check := NewClaudeSettingsCheck()
@@ -375,8 +377,8 @@ func TestClaudeSettingsCheck_MissingDeaconNudge(t *testing.T) {
func TestClaudeSettingsCheck_MissingStopHook(t *testing.T) {
tmpDir := t.TempDir()
// Create stale settings missing Stop hook
mayorSettings := filepath.Join(tmpDir, ".claude", "settings.json")
// Create stale settings missing Stop hook (at correct location)
mayorSettings := filepath.Join(tmpDir, "mayor", ".claude", "settings.json")
createStaleSettings(t, mayorSettings, "Stop")
check := NewClaudeSettingsCheck()
@@ -403,8 +405,9 @@ func TestClaudeSettingsCheck_WrongLocationWitness(t *testing.T) {
tmpDir := t.TempDir()
rigName := "testrig"
// Create settings in wrong location (witness/.claude/ instead of witness/rig/.claude/)
wrongSettings := filepath.Join(tmpDir, rigName, "witness", ".claude", "settings.json")
// Create settings in wrong location (witness/rig/.claude/ instead of witness/.claude/)
// Settings inside git repos should be flagged as wrong location
wrongSettings := filepath.Join(tmpDir, rigName, "witness", "rig", ".claude", "settings.json")
createValidSettings(t, wrongSettings)
check := NewClaudeSettingsCheck()
@@ -431,8 +434,9 @@ func TestClaudeSettingsCheck_WrongLocationRefinery(t *testing.T) {
tmpDir := t.TempDir()
rigName := "testrig"
// Create settings in wrong location (refinery/.claude/ instead of refinery/rig/.claude/)
wrongSettings := filepath.Join(tmpDir, rigName, "refinery", ".claude", "settings.json")
// Create settings in wrong location (refinery/rig/.claude/ instead of refinery/.claude/)
// Settings inside git repos should be flagged as wrong location
wrongSettings := filepath.Join(tmpDir, rigName, "refinery", "rig", ".claude", "settings.json")
createValidSettings(t, wrongSettings)
check := NewClaudeSettingsCheck()
@@ -459,14 +463,15 @@ func TestClaudeSettingsCheck_MultipleStaleFiles(t *testing.T) {
tmpDir := t.TempDir()
rigName := "testrig"
// Create multiple stale settings files
mayorSettings := filepath.Join(tmpDir, ".claude", "settings.json")
// Create multiple stale settings files (all at correct locations)
mayorSettings := filepath.Join(tmpDir, "mayor", ".claude", "settings.json")
createStaleSettings(t, mayorSettings, "PATH")
deaconSettings := filepath.Join(tmpDir, "deacon", ".claude", "settings.json")
createStaleSettings(t, deaconSettings, "Stop")
witnessWrong := filepath.Join(tmpDir, rigName, "witness", ".claude", "settings.json")
// Settings inside git repo (witness/rig/.claude/) are wrong location
witnessWrong := filepath.Join(tmpDir, rigName, "witness", "rig", ".claude", "settings.json")
createValidSettings(t, witnessWrong) // Valid content but wrong location
check := NewClaudeSettingsCheck()
@@ -485,8 +490,8 @@ func TestClaudeSettingsCheck_MultipleStaleFiles(t *testing.T) {
func TestClaudeSettingsCheck_InvalidJSON(t *testing.T) {
tmpDir := t.TempDir()
// Create invalid JSON file
mayorSettings := filepath.Join(tmpDir, ".claude", "settings.json")
// Create invalid JSON file (at correct location)
mayorSettings := filepath.Join(tmpDir, "mayor", ".claude", "settings.json")
if err := os.MkdirAll(filepath.Dir(mayorSettings), 0755); err != nil {
t.Fatal(err)
}
@@ -517,9 +522,9 @@ func TestClaudeSettingsCheck_InvalidJSON(t *testing.T) {
func TestClaudeSettingsCheck_FixDeletesStaleFile(t *testing.T) {
tmpDir := t.TempDir()
// Create stale settings in wrong location (easy to test - just delete, no recreate)
// Create stale settings in wrong location (inside git repo - easy to test - just delete, no recreate)
rigName := "testrig"
wrongSettings := filepath.Join(tmpDir, rigName, "witness", ".claude", "settings.json")
wrongSettings := filepath.Join(tmpDir, rigName, "witness", "rig", ".claude", "settings.json")
createValidSettings(t, wrongSettings)
check := NewClaudeSettingsCheck()
@@ -583,16 +588,16 @@ func TestClaudeSettingsCheck_MixedValidAndStale(t *testing.T) {
tmpDir := t.TempDir()
rigName := "testrig"
// Create valid mayor settings
mayorSettings := filepath.Join(tmpDir, ".claude", "settings.json")
// Create valid mayor settings (at correct location)
mayorSettings := filepath.Join(tmpDir, "mayor", ".claude", "settings.json")
createValidSettings(t, mayorSettings)
// Create stale witness settings (missing PATH)
witnessSettings := filepath.Join(tmpDir, rigName, "witness", "rig", ".claude", "settings.json")
// Create stale witness settings in correct location (missing PATH)
witnessSettings := filepath.Join(tmpDir, rigName, "witness", ".claude", "settings.json")
createStaleSettings(t, witnessSettings, "PATH")
// Create valid refinery settings
refinerySettings := filepath.Join(tmpDir, rigName, "refinery", "rig", ".claude", "settings.json")
// Create valid refinery settings in correct location
refinerySettings := filepath.Join(tmpDir, rigName, "refinery", ".claude", "settings.json")
createValidSettings(t, refinerySettings)
check := NewClaudeSettingsCheck()
@@ -611,3 +616,403 @@ func TestClaudeSettingsCheck_MixedValidAndStale(t *testing.T) {
t.Errorf("expected 1 detail, got %d: %v", len(result.Details), result.Details)
}
}
func TestClaudeSettingsCheck_WrongLocationCrew(t *testing.T) {
tmpDir := t.TempDir()
rigName := "testrig"
// Create settings in wrong location (crew/<name>/.claude/ instead of crew/.claude/)
// Settings inside git repos should be flagged as wrong location
wrongSettings := filepath.Join(tmpDir, rigName, "crew", "agent1", ".claude", "settings.json")
createValidSettings(t, wrongSettings)
check := NewClaudeSettingsCheck()
ctx := &CheckContext{TownRoot: tmpDir}
result := check.Run(ctx)
if result.Status != StatusError {
t.Errorf("expected StatusError for wrong location, got %v", result.Status)
}
found := false
for _, d := range result.Details {
if strings.Contains(d, "wrong location") {
found = true
break
}
}
if !found {
t.Errorf("expected details to mention wrong location, got %v", result.Details)
}
}
func TestClaudeSettingsCheck_WrongLocationPolecat(t *testing.T) {
tmpDir := t.TempDir()
rigName := "testrig"
// Create settings in wrong location (polecats/<name>/.claude/ instead of polecats/.claude/)
// Settings inside git repos should be flagged as wrong location
wrongSettings := filepath.Join(tmpDir, rigName, "polecats", "pc1", ".claude", "settings.json")
createValidSettings(t, wrongSettings)
check := NewClaudeSettingsCheck()
ctx := &CheckContext{TownRoot: tmpDir}
result := check.Run(ctx)
if result.Status != StatusError {
t.Errorf("expected StatusError for wrong location, got %v", result.Status)
}
found := false
for _, d := range result.Details {
if strings.Contains(d, "wrong location") {
found = true
break
}
}
if !found {
t.Errorf("expected details to mention wrong location, got %v", result.Details)
}
}
// initTestGitRepo initializes a git repo in the given directory for settings tests.
func initTestGitRepo(t *testing.T, dir string) {
t.Helper()
cmds := [][]string{
{"git", "init"},
{"git", "config", "user.email", "test@test.com"},
{"git", "config", "user.name", "Test User"},
}
for _, args := range cmds {
cmd := exec.Command(args[0], args[1:]...)
cmd.Dir = dir
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("git command %v failed: %v\n%s", args, err, out)
}
}
}
// gitAddAndCommit adds and commits a file.
func gitAddAndCommit(t *testing.T, repoDir, filePath string) {
t.Helper()
// Get relative path from repo root
relPath, err := filepath.Rel(repoDir, filePath)
if err != nil {
t.Fatal(err)
}
cmds := [][]string{
{"git", "add", relPath},
{"git", "commit", "-m", "Add file"},
}
for _, args := range cmds {
cmd := exec.Command(args[0], args[1:]...)
cmd.Dir = repoDir
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("git command %v failed: %v\n%s", args, err, out)
}
}
}
func TestClaudeSettingsCheck_GitStatusUntracked(t *testing.T) {
tmpDir := t.TempDir()
rigName := "testrig"
// Create a git repo to simulate a source repo
rigDir := filepath.Join(tmpDir, rigName, "witness", "rig")
if err := os.MkdirAll(rigDir, 0755); err != nil {
t.Fatal(err)
}
initTestGitRepo(t, rigDir)
// Create an untracked settings file (not git added)
wrongSettings := filepath.Join(rigDir, ".claude", "settings.json")
createValidSettings(t, wrongSettings)
check := NewClaudeSettingsCheck()
ctx := &CheckContext{TownRoot: tmpDir}
result := check.Run(ctx)
if result.Status != StatusError {
t.Errorf("expected StatusError for wrong location, got %v", result.Status)
}
// Should mention "untracked"
found := false
for _, d := range result.Details {
if strings.Contains(d, "untracked") {
found = true
break
}
}
if !found {
t.Errorf("expected details to mention untracked, got %v", result.Details)
}
}
func TestClaudeSettingsCheck_GitStatusTrackedClean(t *testing.T) {
tmpDir := t.TempDir()
rigName := "testrig"
// Create a git repo to simulate a source repo
rigDir := filepath.Join(tmpDir, rigName, "witness", "rig")
if err := os.MkdirAll(rigDir, 0755); err != nil {
t.Fatal(err)
}
initTestGitRepo(t, rigDir)
// Create settings and commit it (tracked, clean)
wrongSettings := filepath.Join(rigDir, ".claude", "settings.json")
createValidSettings(t, wrongSettings)
gitAddAndCommit(t, rigDir, wrongSettings)
check := NewClaudeSettingsCheck()
ctx := &CheckContext{TownRoot: tmpDir}
result := check.Run(ctx)
if result.Status != StatusError {
t.Errorf("expected StatusError for wrong location, got %v", result.Status)
}
// Should mention "tracked but unmodified"
found := false
for _, d := range result.Details {
if strings.Contains(d, "tracked but unmodified") {
found = true
break
}
}
if !found {
t.Errorf("expected details to mention tracked but unmodified, got %v", result.Details)
}
}
func TestClaudeSettingsCheck_GitStatusTrackedModified(t *testing.T) {
tmpDir := t.TempDir()
rigName := "testrig"
// Create a git repo to simulate a source repo
rigDir := filepath.Join(tmpDir, rigName, "witness", "rig")
if err := os.MkdirAll(rigDir, 0755); err != nil {
t.Fatal(err)
}
initTestGitRepo(t, rigDir)
// Create settings and commit it
wrongSettings := filepath.Join(rigDir, ".claude", "settings.json")
createValidSettings(t, wrongSettings)
gitAddAndCommit(t, rigDir, wrongSettings)
// Modify the file after commit
if err := os.WriteFile(wrongSettings, []byte(`{"modified": true}`), 0644); err != nil {
t.Fatal(err)
}
check := NewClaudeSettingsCheck()
ctx := &CheckContext{TownRoot: tmpDir}
result := check.Run(ctx)
if result.Status != StatusError {
t.Errorf("expected StatusError for wrong location, got %v", result.Status)
}
// Should mention "local modifications"
found := false
for _, d := range result.Details {
if strings.Contains(d, "local modifications") {
found = true
break
}
}
if !found {
t.Errorf("expected details to mention local modifications, got %v", result.Details)
}
// Should also mention manual review
if !strings.Contains(result.FixHint, "manual review") {
t.Errorf("expected fix hint to mention manual review, got %q", result.FixHint)
}
}
func TestClaudeSettingsCheck_FixSkipsModifiedFiles(t *testing.T) {
tmpDir := t.TempDir()
rigName := "testrig"
// Create a git repo to simulate a source repo
rigDir := filepath.Join(tmpDir, rigName, "witness", "rig")
if err := os.MkdirAll(rigDir, 0755); err != nil {
t.Fatal(err)
}
initTestGitRepo(t, rigDir)
// Create settings and commit it
wrongSettings := filepath.Join(rigDir, ".claude", "settings.json")
createValidSettings(t, wrongSettings)
gitAddAndCommit(t, rigDir, wrongSettings)
// Modify the file after commit
if err := os.WriteFile(wrongSettings, []byte(`{"modified": true}`), 0644); err != nil {
t.Fatal(err)
}
check := NewClaudeSettingsCheck()
ctx := &CheckContext{TownRoot: tmpDir}
// Run to detect
result := check.Run(ctx)
if result.Status != StatusError {
t.Fatalf("expected StatusError before fix, got %v", result.Status)
}
// Apply fix - should NOT delete the modified file
if err := check.Fix(ctx); err != nil {
t.Fatalf("Fix failed: %v", err)
}
// Verify file still exists (was skipped)
if _, err := os.Stat(wrongSettings); os.IsNotExist(err) {
t.Error("expected modified file to be preserved, but it was deleted")
}
}
func TestClaudeSettingsCheck_FixDeletesUntrackedFiles(t *testing.T) {
tmpDir := t.TempDir()
rigName := "testrig"
// Create a git repo to simulate a source repo
rigDir := filepath.Join(tmpDir, rigName, "witness", "rig")
if err := os.MkdirAll(rigDir, 0755); err != nil {
t.Fatal(err)
}
initTestGitRepo(t, rigDir)
// Create an untracked settings file (not git added)
wrongSettings := filepath.Join(rigDir, ".claude", "settings.json")
createValidSettings(t, wrongSettings)
check := NewClaudeSettingsCheck()
ctx := &CheckContext{TownRoot: tmpDir}
// Run to detect
result := check.Run(ctx)
if result.Status != StatusError {
t.Fatalf("expected StatusError before fix, got %v", result.Status)
}
// Apply fix - should delete the untracked file
if err := check.Fix(ctx); err != nil {
t.Fatalf("Fix failed: %v", err)
}
// Verify file was deleted
if _, err := os.Stat(wrongSettings); !os.IsNotExist(err) {
t.Error("expected untracked file to be deleted")
}
}
func TestClaudeSettingsCheck_FixDeletesTrackedCleanFiles(t *testing.T) {
tmpDir := t.TempDir()
rigName := "testrig"
// Create a git repo to simulate a source repo
rigDir := filepath.Join(tmpDir, rigName, "witness", "rig")
if err := os.MkdirAll(rigDir, 0755); err != nil {
t.Fatal(err)
}
initTestGitRepo(t, rigDir)
// Create settings and commit it (tracked, clean)
wrongSettings := filepath.Join(rigDir, ".claude", "settings.json")
createValidSettings(t, wrongSettings)
gitAddAndCommit(t, rigDir, wrongSettings)
check := NewClaudeSettingsCheck()
ctx := &CheckContext{TownRoot: tmpDir}
// Run to detect
result := check.Run(ctx)
if result.Status != StatusError {
t.Fatalf("expected StatusError before fix, got %v", result.Status)
}
// Apply fix - should delete the tracked clean file
if err := check.Fix(ctx); err != nil {
t.Fatalf("Fix failed: %v", err)
}
// Verify file was deleted
if _, err := os.Stat(wrongSettings); !os.IsNotExist(err) {
t.Error("expected tracked clean file to be deleted")
}
}
func TestClaudeSettingsCheck_DetectsStaleCLAUDEmdAtTownRoot(t *testing.T) {
tmpDir := t.TempDir()
// Create CLAUDE.md at town root (wrong location)
staleCLAUDEmd := filepath.Join(tmpDir, "CLAUDE.md")
if err := os.WriteFile(staleCLAUDEmd, []byte("# Mayor Context\n"), 0644); err != nil {
t.Fatal(err)
}
check := NewClaudeSettingsCheck()
ctx := &CheckContext{TownRoot: tmpDir}
result := check.Run(ctx)
if result.Status != StatusError {
t.Errorf("expected StatusError for stale CLAUDE.md at town root, got %v", result.Status)
}
// Should mention wrong location
found := false
for _, d := range result.Details {
if strings.Contains(d, "CLAUDE.md") && strings.Contains(d, "wrong location") {
found = true
break
}
}
if !found {
t.Errorf("expected details to mention CLAUDE.md wrong location, got %v", result.Details)
}
}
func TestClaudeSettingsCheck_FixMovesCLAUDEmdToMayor(t *testing.T) {
tmpDir := t.TempDir()
// Create mayor directory (needed for fix to create CLAUDE.md there)
mayorDir := filepath.Join(tmpDir, "mayor")
if err := os.MkdirAll(mayorDir, 0755); err != nil {
t.Fatal(err)
}
// Create CLAUDE.md at town root (wrong location)
staleCLAUDEmd := filepath.Join(tmpDir, "CLAUDE.md")
if err := os.WriteFile(staleCLAUDEmd, []byte("# Mayor Context\n"), 0644); err != nil {
t.Fatal(err)
}
check := NewClaudeSettingsCheck()
ctx := &CheckContext{TownRoot: tmpDir}
// Run to detect
result := check.Run(ctx)
if result.Status != StatusError {
t.Fatalf("expected StatusError before fix, got %v", result.Status)
}
// Apply fix
if err := check.Fix(ctx); err != nil {
t.Fatalf("Fix failed: %v", err)
}
// Verify old file was deleted
if _, err := os.Stat(staleCLAUDEmd); !os.IsNotExist(err) {
t.Error("expected CLAUDE.md at town root to be deleted")
}
// Verify new file was created at mayor/
correctCLAUDEmd := filepath.Join(mayorDir, "CLAUDE.md")
if _, err := os.Stat(correctCLAUDEmd); os.IsNotExist(err) {
t.Error("expected CLAUDE.md to be created at mayor/")
}
}

View File

@@ -4,13 +4,15 @@ import (
"fmt"
"os"
"path/filepath"
"strings"
"github.com/steveyegge/gastown/internal/git"
)
// SparseCheckoutCheck verifies that git clones/worktrees have sparse checkout configured
// to exclude .claude/ from source repos. This ensures source repo settings don't override
// Gas Town agent settings.
// to exclude Claude Code context files from source repos. This ensures source repo settings
// and instructions don't override Gas Town agent configuration.
// Excluded files: .claude/, CLAUDE.md, CLAUDE.local.md, .mcp.json
type SparseCheckoutCheck struct {
FixableCheck
rigPath string
@@ -23,7 +25,7 @@ func NewSparseCheckoutCheck() *SparseCheckoutCheck {
FixableCheck: FixableCheck{
BaseCheck: BaseCheck{
CheckName: "sparse-checkout",
CheckDescription: "Verify sparse checkout is configured to exclude .claude/",
CheckDescription: "Verify sparse checkout excludes Claude context files (.claude/, CLAUDE.md, etc.)",
},
},
}
@@ -84,7 +86,7 @@ func (c *SparseCheckoutCheck) Run(ctx *CheckContext) *CheckResult {
return &CheckResult{
Name: c.Name(),
Status: StatusOK,
Message: "All repos have sparse checkout configured to exclude .claude/",
Message: "All repos have sparse checkout configured to exclude Claude context files",
}
}
@@ -107,13 +109,22 @@ func (c *SparseCheckoutCheck) Run(ctx *CheckContext) *CheckResult {
}
}
// Fix configures sparse checkout for affected repos to exclude .claude/.
// Fix configures sparse checkout for affected repos to exclude Claude context files.
func (c *SparseCheckoutCheck) Fix(ctx *CheckContext) error {
for _, repoPath := range c.affectedRepos {
if err := git.ConfigureSparseCheckout(repoPath); err != nil {
relPath, _ := filepath.Rel(c.rigPath, repoPath)
return fmt.Errorf("failed to configure sparse checkout for %s: %w", relPath, err)
}
// Check if any excluded files remain (untracked or modified files won't be removed by git read-tree)
if remaining := git.CheckExcludedFilesExist(repoPath); len(remaining) > 0 {
relPath, _ := filepath.Rel(c.rigPath, repoPath)
return fmt.Errorf("sparse checkout configured for %s but these files still exist: %s\n"+
"These files are untracked or modified and were not removed by git.\n"+
"Please manually remove or revert these files in %s",
relPath, strings.Join(remaining, ", "), repoPath)
}
}
return nil
}

View File

@@ -345,3 +345,309 @@ func TestSparseCheckoutCheck_NonGitDirSkipped(t *testing.T) {
t.Errorf("expected StatusOK when no git repos, got %v", result.Status)
}
}
func TestSparseCheckoutCheck_VerifiesAllPatterns(t *testing.T) {
tmpDir := t.TempDir()
rigName := "testrig"
rigDir := filepath.Join(tmpDir, rigName)
// Create git repo
mayorRig := filepath.Join(rigDir, "mayor", "rig")
initGitRepo(t, mayorRig)
// Configure sparse checkout using our function
if err := git.ConfigureSparseCheckout(mayorRig); err != nil {
t.Fatalf("ConfigureSparseCheckout failed: %v", err)
}
// Read the sparse-checkout file and verify all patterns are present
sparseFile := filepath.Join(mayorRig, ".git", "info", "sparse-checkout")
content, err := os.ReadFile(sparseFile)
if err != nil {
t.Fatalf("Failed to read sparse-checkout file: %v", err)
}
contentStr := string(content)
// Verify all required patterns are present
requiredPatterns := []string{
"!/.claude/", // Settings, rules, agents, commands
"!/CLAUDE.md", // Primary context file
"!/CLAUDE.local.md", // Personal context file
"!/.mcp.json", // MCP server configuration
}
for _, pattern := range requiredPatterns {
if !strings.Contains(contentStr, pattern) {
t.Errorf("sparse-checkout file missing pattern %q, got:\n%s", pattern, contentStr)
}
}
}
func TestSparseCheckoutCheck_LegacyPatternNotSufficient(t *testing.T) {
tmpDir := t.TempDir()
rigName := "testrig"
rigDir := filepath.Join(tmpDir, rigName)
// Create git repo
mayorRig := filepath.Join(rigDir, "mayor", "rig")
initGitRepo(t, mayorRig)
// Manually configure sparse checkout with only legacy .claude/ pattern (missing CLAUDE.md)
cmd := exec.Command("git", "config", "core.sparseCheckout", "true")
cmd.Dir = mayorRig
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("git config failed: %v\n%s", err, out)
}
sparseFile := filepath.Join(mayorRig, ".git", "info", "sparse-checkout")
if err := os.MkdirAll(filepath.Dir(sparseFile), 0755); err != nil {
t.Fatal(err)
}
// Only include legacy pattern, missing CLAUDE.md
if err := os.WriteFile(sparseFile, []byte("/*\n!.claude/\n"), 0644); err != nil {
t.Fatal(err)
}
check := NewSparseCheckoutCheck()
ctx := &CheckContext{TownRoot: tmpDir, RigName: rigName}
result := check.Run(ctx)
// Should fail because CLAUDE.md pattern is missing
if result.Status != StatusError {
t.Errorf("expected StatusError for legacy-only pattern, got %v", result.Status)
}
}
func TestSparseCheckoutCheck_FixUpgradesLegacyPatterns(t *testing.T) {
tmpDir := t.TempDir()
rigName := "testrig"
rigDir := filepath.Join(tmpDir, rigName)
// Create git repo with legacy sparse checkout (only .claude/)
mayorRig := filepath.Join(rigDir, "mayor", "rig")
initGitRepo(t, mayorRig)
cmd := exec.Command("git", "config", "core.sparseCheckout", "true")
cmd.Dir = mayorRig
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("git config failed: %v\n%s", err, out)
}
sparseFile := filepath.Join(mayorRig, ".git", "info", "sparse-checkout")
if err := os.MkdirAll(filepath.Dir(sparseFile), 0755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(sparseFile, []byte("/*\n!.claude/\n"), 0644); err != nil {
t.Fatal(err)
}
check := NewSparseCheckoutCheck()
ctx := &CheckContext{TownRoot: tmpDir, RigName: rigName}
// Verify fix is needed
result := check.Run(ctx)
if result.Status != StatusError {
t.Fatalf("expected StatusError before fix, got %v", result.Status)
}
// Apply fix
if err := check.Fix(ctx); err != nil {
t.Fatalf("Fix failed: %v", err)
}
// Verify all patterns are now present
content, err := os.ReadFile(sparseFile)
if err != nil {
t.Fatalf("Failed to read sparse-checkout file: %v", err)
}
contentStr := string(content)
requiredPatterns := []string{"!/.claude/", "!/CLAUDE.md", "!/CLAUDE.local.md", "!/.mcp.json"}
for _, pattern := range requiredPatterns {
if !strings.Contains(contentStr, pattern) {
t.Errorf("after fix, sparse-checkout file missing pattern %q", pattern)
}
}
// Verify check now passes
result = check.Run(ctx)
if result.Status != StatusOK {
t.Errorf("expected StatusOK after fix, got %v", result.Status)
}
}
func TestSparseCheckoutCheck_FixFailsWithUntrackedCLAUDEMD(t *testing.T) {
tmpDir := t.TempDir()
rigName := "testrig"
rigDir := filepath.Join(tmpDir, rigName)
// Create git repo without sparse checkout
mayorRig := filepath.Join(rigDir, "mayor", "rig")
initGitRepo(t, mayorRig)
// Create untracked CLAUDE.md (not added to git)
claudeFile := filepath.Join(mayorRig, "CLAUDE.md")
if err := os.WriteFile(claudeFile, []byte("# Untracked context\n"), 0644); err != nil {
t.Fatal(err)
}
check := NewSparseCheckoutCheck()
ctx := &CheckContext{TownRoot: tmpDir, RigName: rigName}
// Verify fix is needed
result := check.Run(ctx)
if result.Status != StatusError {
t.Fatalf("expected StatusError before fix, got %v", result.Status)
}
// Fix should fail because CLAUDE.md is untracked and won't be removed
err := check.Fix(ctx)
if err == nil {
t.Fatal("expected Fix to return error for untracked CLAUDE.md, but it succeeded")
}
// Verify error message is helpful
if !strings.Contains(err.Error(), "CLAUDE.md") {
t.Errorf("expected error to mention CLAUDE.md, got: %v", err)
}
if !strings.Contains(err.Error(), "untracked or modified") {
t.Errorf("expected error to explain files are untracked/modified, got: %v", err)
}
if !strings.Contains(err.Error(), "manually remove") {
t.Errorf("expected error to mention manual removal, got: %v", err)
}
}
func TestSparseCheckoutCheck_FixFailsWithUntrackedClaudeDir(t *testing.T) {
tmpDir := t.TempDir()
rigName := "testrig"
rigDir := filepath.Join(tmpDir, rigName)
// Create git repo without sparse checkout
mayorRig := filepath.Join(rigDir, "mayor", "rig")
initGitRepo(t, mayorRig)
// Create untracked .claude/ directory (not added to git)
claudeDir := filepath.Join(mayorRig, ".claude")
if err := os.MkdirAll(claudeDir, 0755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(claudeDir, "settings.json"), []byte("{}"), 0644); err != nil {
t.Fatal(err)
}
check := NewSparseCheckoutCheck()
ctx := &CheckContext{TownRoot: tmpDir, RigName: rigName}
// Verify fix is needed
result := check.Run(ctx)
if result.Status != StatusError {
t.Fatalf("expected StatusError before fix, got %v", result.Status)
}
// Fix should fail because .claude/ is untracked and won't be removed
err := check.Fix(ctx)
if err == nil {
t.Fatal("expected Fix to return error for untracked .claude/, but it succeeded")
}
// Verify error message mentions .claude
if !strings.Contains(err.Error(), ".claude") {
t.Errorf("expected error to mention .claude, got: %v", err)
}
}
func TestSparseCheckoutCheck_FixFailsWithModifiedCLAUDEMD(t *testing.T) {
tmpDir := t.TempDir()
rigName := "testrig"
rigDir := filepath.Join(tmpDir, rigName)
// Create git repo without sparse checkout
mayorRig := filepath.Join(rigDir, "mayor", "rig")
initGitRepo(t, mayorRig)
// Add and commit CLAUDE.md to the repo
claudeFile := filepath.Join(mayorRig, "CLAUDE.md")
if err := os.WriteFile(claudeFile, []byte("# Original context\n"), 0644); err != nil {
t.Fatal(err)
}
cmd := exec.Command("git", "add", "CLAUDE.md")
cmd.Dir = mayorRig
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("git add failed: %v\n%s", err, out)
}
cmd = exec.Command("git", "commit", "-m", "Add CLAUDE.md")
cmd.Dir = mayorRig
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("git commit failed: %v\n%s", err, out)
}
// Now modify CLAUDE.md without committing (making it "dirty")
if err := os.WriteFile(claudeFile, []byte("# Modified context - local changes\n"), 0644); err != nil {
t.Fatal(err)
}
check := NewSparseCheckoutCheck()
ctx := &CheckContext{TownRoot: tmpDir, RigName: rigName}
// Verify fix is needed
result := check.Run(ctx)
if result.Status != StatusError {
t.Fatalf("expected StatusError before fix, got %v", result.Status)
}
// Fix should fail because CLAUDE.md is modified and git won't remove it
err := check.Fix(ctx)
if err == nil {
t.Fatal("expected Fix to return error for modified CLAUDE.md, but it succeeded")
}
// Verify error message is helpful
if !strings.Contains(err.Error(), "CLAUDE.md") {
t.Errorf("expected error to mention CLAUDE.md, got: %v", err)
}
}
func TestSparseCheckoutCheck_FixFailsWithMultipleProblems(t *testing.T) {
tmpDir := t.TempDir()
rigName := "testrig"
rigDir := filepath.Join(tmpDir, rigName)
// Create git repo without sparse checkout
mayorRig := filepath.Join(rigDir, "mayor", "rig")
initGitRepo(t, mayorRig)
// Create multiple untracked context files
if err := os.WriteFile(filepath.Join(mayorRig, "CLAUDE.md"), []byte("# Context\n"), 0644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(mayorRig, ".mcp.json"), []byte("{}"), 0644); err != nil {
t.Fatal(err)
}
check := NewSparseCheckoutCheck()
ctx := &CheckContext{TownRoot: tmpDir, RigName: rigName}
// Verify fix is needed
result := check.Run(ctx)
if result.Status != StatusError {
t.Fatalf("expected StatusError before fix, got %v", result.Status)
}
// Fix should fail and list multiple files
err := check.Fix(ctx)
if err == nil {
t.Fatal("expected Fix to return error for multiple untracked files, but it succeeded")
}
// Verify error mentions both files
errStr := err.Error()
if !strings.Contains(errStr, "CLAUDE.md") {
t.Errorf("expected error to mention CLAUDE.md, got: %v", err)
}
if !strings.Contains(errStr, ".mcp.json") {
t.Errorf("expected error to mention .mcp.json, got: %v", err)
}
}