From 2aadb0165b7e6b7711e80ece526c49e897c82581 Mon Sep 17 00:00:00 2001 From: Dustin Smith Date: Sun, 18 Jan 2026 17:08:19 +0700 Subject: [PATCH] fix: ensure gitignore patterns on role creation Add EnsureGitignorePatterns to rig package that ensures .gitignore has required Gas Town patterns (.runtime/, .claude/, .beads/, .logs/). Called from crew and polecat managers when creating new workers. This prevents runtime-gitignore warnings from gt doctor. The function: - Creates .gitignore if it doesn't exist - Appends missing patterns to existing files - Recognizes pattern variants (.runtime vs .runtime/) - Adds "# Gas Town" header when appending Includes comprehensive tests for all scenarios. --- internal/crew/manager.go | 7 ++ internal/polecat/manager.go | 10 ++ internal/rig/overlay.go | 70 +++++++++++++ internal/rig/overlay_test.go | 190 +++++++++++++++++++++++++++++++++++ 4 files changed, 277 insertions(+) diff --git a/internal/crew/manager.go b/internal/crew/manager.go index 35d61f55..0f38e6cf 100644 --- a/internal/crew/manager.go +++ b/internal/crew/manager.go @@ -188,6 +188,12 @@ func (m *Manager) Add(name string, createBranch bool) (*CrewWorker, error) { fmt.Printf("Warning: could not copy overlay files: %v\n", err) } + // Ensure .gitignore has required Gas Town patterns + if err := rig.EnsureGitignorePatterns(crewPath); err != nil { + // Non-fatal - log warning but continue + fmt.Printf("Warning: could not update .gitignore: %v\n", err) + } + // NOTE: Slash commands (.claude/commands/) are provisioned at town level by gt install. // All agents inherit them via Claude's directory traversal - no per-workspace copies needed. @@ -581,3 +587,4 @@ func (m *Manager) IsRunning(name string) (bool, error) { sessionID := m.SessionName(name) return t.HasSession(sessionID) } + diff --git a/internal/polecat/manager.go b/internal/polecat/manager.go index 47ee857c..737521cb 100644 --- a/internal/polecat/manager.go +++ b/internal/polecat/manager.go @@ -334,6 +334,11 @@ func (m *Manager) AddWithOptions(name string, opts AddOptions) (*Polecat, error) fmt.Printf("Warning: could not copy overlay files: %v\n", err) } + // Ensure .gitignore has required Gas Town patterns + if err := rig.EnsureGitignorePatterns(clonePath); err != nil { + fmt.Printf("Warning: could not update .gitignore: %v\n", err) + } + // Run setup hooks from .runtime/setup-hooks/. // These hooks can inject local git config, copy secrets, or perform other setup tasks. if err := rig.RunSetupHooks(m.rig.Path, clonePath); err != nil { @@ -638,6 +643,11 @@ func (m *Manager) RepairWorktreeWithOptions(name string, force bool, opts AddOpt fmt.Printf("Warning: could not copy overlay files: %v\n", err) } + // Ensure .gitignore has required Gas Town patterns + if err := rig.EnsureGitignorePatterns(newClonePath); err != nil { + fmt.Printf("Warning: could not update .gitignore: %v\n", err) + } + // NOTE: Slash commands inherited from town level - no per-workspace copies needed. // Create or reopen agent bead for ZFC compliance diff --git a/internal/rig/overlay.go b/internal/rig/overlay.go index fa0d4a97..e764b40b 100644 --- a/internal/rig/overlay.go +++ b/internal/rig/overlay.go @@ -5,6 +5,7 @@ import ( "io" "os" "path/filepath" + "strings" ) // CopyOverlay copies files from /.runtime/overlay/ to the destination path. @@ -55,6 +56,75 @@ func CopyOverlay(rigPath, destPath string) error { return nil } +// EnsureGitignorePatterns ensures the .gitignore has required Gas Town patterns. +// This is called after cloning to add patterns that may be missing from the source repo. +func EnsureGitignorePatterns(worktreePath string) error { + gitignorePath := filepath.Join(worktreePath, ".gitignore") + + // Required patterns for Gas Town worktrees + requiredPatterns := []string{ + ".runtime/", + ".claude/", + ".beads/", + ".logs/", + } + + // Read existing gitignore content + var existingContent string + if data, err := os.ReadFile(gitignorePath); err == nil { + existingContent = string(data) + } + + // Find missing patterns + var missing []string + for _, pattern := range requiredPatterns { + // Check various forms: .runtime, .runtime/, /.runtime, etc. + found := false + for _, line := range strings.Split(existingContent, "\n") { + line = strings.TrimSpace(line) + if line == pattern || line == strings.TrimSuffix(pattern, "/") || + line == "/"+pattern || line == "/"+strings.TrimSuffix(pattern, "/") { + found = true + break + } + } + if !found { + missing = append(missing, pattern) + } + } + + if len(missing) == 0 { + return nil // All patterns present + } + + // Append missing patterns + f, err := os.OpenFile(gitignorePath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) + if err != nil { + return fmt.Errorf("opening .gitignore: %w", err) + } + defer f.Close() + + // Add header if appending to existing file + if existingContent != "" && !strings.HasSuffix(existingContent, "\n") { + if _, err := f.WriteString("\n"); err != nil { + return err + } + } + if existingContent != "" { + if _, err := f.WriteString("\n# Gas Town (added by gt)\n"); err != nil { + return err + } + } + + for _, pattern := range missing { + if _, err := f.WriteString(pattern + "\n"); err != nil { + return err + } + } + + return nil +} + // copyFilePreserveMode copies a file from src to dst, preserving the source file's permissions. func copyFilePreserveMode(src, dst string) error { // Get source file info for permissions diff --git a/internal/rig/overlay_test.go b/internal/rig/overlay_test.go index b21768f5..faeccf66 100644 --- a/internal/rig/overlay_test.go +++ b/internal/rig/overlay_test.go @@ -249,3 +249,193 @@ func TestCopyFilePreserveMode_NonexistentSource(t *testing.T) { t.Error("copyFilePreserveMode() with nonexistent source should return error") } } + +func TestEnsureGitignorePatterns_CreatesNewFile(t *testing.T) { + tmpDir := t.TempDir() + + err := EnsureGitignorePatterns(tmpDir) + if err != nil { + t.Fatalf("EnsureGitignorePatterns() error = %v", err) + } + + content, err := os.ReadFile(filepath.Join(tmpDir, ".gitignore")) + if err != nil { + t.Fatalf("Failed to read .gitignore: %v", err) + } + + // Check all required patterns are present + patterns := []string{".runtime/", ".claude/", ".beads/", ".logs/"} + for _, pattern := range patterns { + if !containsLine(string(content), pattern) { + t.Errorf(".gitignore missing pattern %q", pattern) + } + } +} + +func TestEnsureGitignorePatterns_AppendsToExisting(t *testing.T) { + tmpDir := t.TempDir() + + // Create existing .gitignore with some content + existing := "node_modules/\n*.log\n" + if err := os.WriteFile(filepath.Join(tmpDir, ".gitignore"), []byte(existing), 0644); err != nil { + t.Fatalf("Failed to create .gitignore: %v", err) + } + + err := EnsureGitignorePatterns(tmpDir) + if err != nil { + t.Fatalf("EnsureGitignorePatterns() error = %v", err) + } + + content, err := os.ReadFile(filepath.Join(tmpDir, ".gitignore")) + if err != nil { + t.Fatalf("Failed to read .gitignore: %v", err) + } + + // Should preserve existing content + if !containsLine(string(content), "node_modules/") { + t.Error("Existing pattern node_modules/ was removed") + } + + // Should add header + if !containsLine(string(content), "# Gas Town (added by gt)") { + t.Error("Missing Gas Town header comment") + } + + // Should add required patterns + patterns := []string{".runtime/", ".claude/", ".beads/", ".logs/"} + for _, pattern := range patterns { + if !containsLine(string(content), pattern) { + t.Errorf(".gitignore missing pattern %q", pattern) + } + } +} + +func TestEnsureGitignorePatterns_SkipsExistingPatterns(t *testing.T) { + tmpDir := t.TempDir() + + // Create existing .gitignore with some Gas Town patterns already + existing := ".runtime/\n.claude/\n" + if err := os.WriteFile(filepath.Join(tmpDir, ".gitignore"), []byte(existing), 0644); err != nil { + t.Fatalf("Failed to create .gitignore: %v", err) + } + + err := EnsureGitignorePatterns(tmpDir) + if err != nil { + t.Fatalf("EnsureGitignorePatterns() error = %v", err) + } + + content, err := os.ReadFile(filepath.Join(tmpDir, ".gitignore")) + if err != nil { + t.Fatalf("Failed to read .gitignore: %v", err) + } + + // Should not duplicate existing patterns + count := countOccurrences(string(content), ".runtime/") + if count != 1 { + t.Errorf(".runtime/ appears %d times, expected 1", count) + } + + // Should add missing patterns + if !containsLine(string(content), ".beads/") { + t.Error(".gitignore missing pattern .beads/") + } + if !containsLine(string(content), ".logs/") { + t.Error(".gitignore missing pattern .logs/") + } +} + +func TestEnsureGitignorePatterns_RecognizesVariants(t *testing.T) { + tmpDir := t.TempDir() + + // Create existing .gitignore with variant patterns (without trailing slash) + existing := ".runtime\n/.claude\n" + if err := os.WriteFile(filepath.Join(tmpDir, ".gitignore"), []byte(existing), 0644); err != nil { + t.Fatalf("Failed to create .gitignore: %v", err) + } + + err := EnsureGitignorePatterns(tmpDir) + if err != nil { + t.Fatalf("EnsureGitignorePatterns() error = %v", err) + } + + content, err := os.ReadFile(filepath.Join(tmpDir, ".gitignore")) + if err != nil { + t.Fatalf("Failed to read .gitignore: %v", err) + } + + // Should recognize variants and not add duplicates + // .runtime (no slash) should count as .runtime/ + if containsLine(string(content), ".runtime/") && containsLine(string(content), ".runtime") { + // Only one should be present unless they're the same line + runtimeCount := countOccurrences(string(content), ".runtime") + if runtimeCount > 1 { + t.Errorf(".runtime appears %d times (variant detection failed)", runtimeCount) + } + } +} + +func TestEnsureGitignorePatterns_AllPatternsPresent(t *testing.T) { + tmpDir := t.TempDir() + + // Create existing .gitignore with all required patterns + existing := ".runtime/\n.claude/\n.beads/\n.logs/\n" + if err := os.WriteFile(filepath.Join(tmpDir, ".gitignore"), []byte(existing), 0644); err != nil { + t.Fatalf("Failed to create .gitignore: %v", err) + } + + err := EnsureGitignorePatterns(tmpDir) + if err != nil { + t.Fatalf("EnsureGitignorePatterns() error = %v", err) + } + + content, err := os.ReadFile(filepath.Join(tmpDir, ".gitignore")) + if err != nil { + t.Fatalf("Failed to read .gitignore: %v", err) + } + + // File should be unchanged (no header added) + if containsLine(string(content), "# Gas Town") { + t.Error("Should not add header when all patterns already present") + } + + // Content should match original + if string(content) != existing { + t.Errorf("File was modified when it shouldn't be.\nGot: %q\nWant: %q", string(content), existing) + } +} + +// Helper functions + +func containsLine(content, pattern string) bool { + for _, line := range splitLines(content) { + if line == pattern { + return true + } + } + return false +} + +func countOccurrences(content, pattern string) int { + count := 0 + for _, line := range splitLines(content) { + if line == pattern { + count++ + } + } + return count +} + +func splitLines(content string) []string { + var lines []string + start := 0 + for i, c := range content { + if c == '\n' { + lines = append(lines, content[start:i]) + start = i + 1 + } + } + if start < len(content) { + lines = append(lines, content[start:]) + } + return lines +}