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.
This commit is contained in:
committed by
Steve Yegge
parent
05ea767149
commit
2aadb0165b
@@ -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)
|
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.
|
// 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.
|
// 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)
|
sessionID := m.SessionName(name)
|
||||||
return t.HasSession(sessionID)
|
return t.HasSession(sessionID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
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/.
|
// Run setup hooks from .runtime/setup-hooks/.
|
||||||
// These hooks can inject local git config, copy secrets, or perform other setup tasks.
|
// These hooks can inject local git config, copy secrets, or perform other setup tasks.
|
||||||
if err := rig.RunSetupHooks(m.rig.Path, clonePath); err != nil {
|
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)
|
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.
|
// NOTE: Slash commands inherited from town level - no per-workspace copies needed.
|
||||||
|
|
||||||
// Create or reopen agent bead for ZFC compliance
|
// Create or reopen agent bead for ZFC compliance
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
// CopyOverlay copies files from <rigPath>/.runtime/overlay/ to the destination path.
|
// CopyOverlay copies files from <rigPath>/.runtime/overlay/ to the destination path.
|
||||||
@@ -55,6 +56,75 @@ func CopyOverlay(rigPath, destPath string) error {
|
|||||||
return nil
|
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.
|
// copyFilePreserveMode copies a file from src to dst, preserving the source file's permissions.
|
||||||
func copyFilePreserveMode(src, dst string) error {
|
func copyFilePreserveMode(src, dst string) error {
|
||||||
// Get source file info for permissions
|
// Get source file info for permissions
|
||||||
|
|||||||
@@ -249,3 +249,193 @@ func TestCopyFilePreserveMode_NonexistentSource(t *testing.T) {
|
|||||||
t.Error("copyFilePreserveMode() with nonexistent source should return error")
|
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
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user