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.
442 lines
12 KiB
Go
442 lines
12 KiB
Go
package rig
|
|
|
|
import (
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
)
|
|
|
|
func TestCopyOverlay_NoOverlayDirectory(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
destDir := t.TempDir()
|
|
|
|
// No overlay directory exists
|
|
err := CopyOverlay(tmpDir, destDir)
|
|
if err != nil {
|
|
t.Errorf("CopyOverlay() with no overlay directory should return nil, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestCopyOverlay_CopiesFiles(t *testing.T) {
|
|
rigDir := t.TempDir()
|
|
destDir := t.TempDir()
|
|
|
|
// Create overlay directory with test files
|
|
overlayDir := filepath.Join(rigDir, ".runtime", "overlay")
|
|
if err := os.MkdirAll(overlayDir, 0755); err != nil {
|
|
t.Fatalf("Failed to create overlay dir: %v", err)
|
|
}
|
|
|
|
// Create test files
|
|
testFile1 := filepath.Join(overlayDir, "test1.txt")
|
|
testFile2 := filepath.Join(overlayDir, "test2.txt")
|
|
|
|
if err := os.WriteFile(testFile1, []byte("content1"), 0644); err != nil {
|
|
t.Fatalf("Failed to create test file: %v", err)
|
|
}
|
|
if err := os.WriteFile(testFile2, []byte("content2"), 0644); err != nil {
|
|
t.Fatalf("Failed to create test file: %v", err)
|
|
}
|
|
|
|
// Copy overlay
|
|
err := CopyOverlay(rigDir, destDir)
|
|
if err != nil {
|
|
t.Fatalf("CopyOverlay() error = %v", err)
|
|
}
|
|
|
|
// Verify files were copied
|
|
destFile1 := filepath.Join(destDir, "test1.txt")
|
|
destFile2 := filepath.Join(destDir, "test2.txt")
|
|
|
|
content1, err := os.ReadFile(destFile1)
|
|
if err != nil {
|
|
t.Errorf("File test1.txt was not copied: %v", err)
|
|
}
|
|
if string(content1) != "content1" {
|
|
t.Errorf("test1.txt content = %q, want %q", string(content1), "content1")
|
|
}
|
|
|
|
content2, err := os.ReadFile(destFile2)
|
|
if err != nil {
|
|
t.Errorf("File test2.txt was not copied: %v", err)
|
|
}
|
|
if string(content2) != "content2" {
|
|
t.Errorf("test2.txt content = %q, want %q", string(content2), "content2")
|
|
}
|
|
}
|
|
|
|
func TestCopyOverlay_PreservesPermissions(t *testing.T) {
|
|
rigDir := t.TempDir()
|
|
destDir := t.TempDir()
|
|
|
|
// Create overlay directory with a file
|
|
overlayDir := filepath.Join(rigDir, ".runtime", "overlay")
|
|
if err := os.MkdirAll(overlayDir, 0755); err != nil {
|
|
t.Fatalf("Failed to create overlay dir: %v", err)
|
|
}
|
|
|
|
testFile := filepath.Join(overlayDir, "test.txt")
|
|
if err := os.WriteFile(testFile, []byte("content"), 0755); err != nil {
|
|
t.Fatalf("Failed to create test file: %v", err)
|
|
}
|
|
|
|
// Copy overlay
|
|
err := CopyOverlay(rigDir, destDir)
|
|
if err != nil {
|
|
t.Fatalf("CopyOverlay() error = %v", err)
|
|
}
|
|
|
|
// Verify permissions were preserved
|
|
srcInfo, _ := os.Stat(testFile)
|
|
destInfo, err := os.Stat(filepath.Join(destDir, "test.txt"))
|
|
if err != nil {
|
|
t.Fatalf("Failed to stat destination file: %v", err)
|
|
}
|
|
|
|
if srcInfo.Mode().Perm() != destInfo.Mode().Perm() {
|
|
t.Errorf("Permissions not preserved: src=%v, dest=%v", srcInfo.Mode(), destInfo.Mode())
|
|
}
|
|
}
|
|
|
|
func TestCopyOverlay_SkipsSubdirectories(t *testing.T) {
|
|
rigDir := t.TempDir()
|
|
destDir := t.TempDir()
|
|
|
|
// Create overlay directory with a subdirectory
|
|
overlayDir := filepath.Join(rigDir, ".runtime", "overlay")
|
|
if err := os.MkdirAll(overlayDir, 0755); err != nil {
|
|
t.Fatalf("Failed to create overlay dir: %v", err)
|
|
}
|
|
|
|
// Create a subdirectory
|
|
subDir := filepath.Join(overlayDir, "subdir")
|
|
if err := os.MkdirAll(subDir, 0755); err != nil {
|
|
t.Fatalf("Failed to create subdirectory: %v", err)
|
|
}
|
|
|
|
// Create a file in the overlay root
|
|
testFile := filepath.Join(overlayDir, "test.txt")
|
|
if err := os.WriteFile(testFile, []byte("content"), 0644); err != nil {
|
|
t.Fatalf("Failed to create test file: %v", err)
|
|
}
|
|
|
|
// Create a file in the subdirectory
|
|
subFile := filepath.Join(subDir, "sub.txt")
|
|
if err := os.WriteFile(subFile, []byte("subcontent"), 0644); err != nil {
|
|
t.Fatalf("Failed to create sub file: %v", err)
|
|
}
|
|
|
|
// Copy overlay
|
|
err := CopyOverlay(rigDir, destDir)
|
|
if err != nil {
|
|
t.Fatalf("CopyOverlay() error = %v", err)
|
|
}
|
|
|
|
// Verify root file was copied
|
|
if _, err := os.Stat(filepath.Join(destDir, "test.txt")); err != nil {
|
|
t.Error("Root file should be copied")
|
|
}
|
|
|
|
// Verify subdirectory was NOT copied
|
|
if _, err := os.Stat(filepath.Join(destDir, "subdir")); err == nil {
|
|
t.Error("Subdirectory should not be copied")
|
|
}
|
|
if _, err := os.Stat(filepath.Join(destDir, "subdir", "sub.txt")); err == nil {
|
|
t.Error("File in subdirectory should not be copied")
|
|
}
|
|
}
|
|
|
|
func TestCopyOverlay_EmptyOverlay(t *testing.T) {
|
|
rigDir := t.TempDir()
|
|
destDir := t.TempDir()
|
|
|
|
// Create empty overlay directory
|
|
overlayDir := filepath.Join(rigDir, ".runtime", "overlay")
|
|
if err := os.MkdirAll(overlayDir, 0755); err != nil {
|
|
t.Fatalf("Failed to create overlay dir: %v", err)
|
|
}
|
|
|
|
// Copy overlay
|
|
err := CopyOverlay(rigDir, destDir)
|
|
if err != nil {
|
|
t.Fatalf("CopyOverlay() error = %v", err)
|
|
}
|
|
|
|
// Should succeed without errors
|
|
}
|
|
|
|
func TestCopyOverlay_OverwritesExisting(t *testing.T) {
|
|
rigDir := t.TempDir()
|
|
destDir := t.TempDir()
|
|
|
|
// Create overlay directory with test file
|
|
overlayDir := filepath.Join(rigDir, ".runtime", "overlay")
|
|
if err := os.MkdirAll(overlayDir, 0755); err != nil {
|
|
t.Fatalf("Failed to create overlay dir: %v", err)
|
|
}
|
|
|
|
testFile := filepath.Join(overlayDir, "test.txt")
|
|
if err := os.WriteFile(testFile, []byte("new content"), 0644); err != nil {
|
|
t.Fatalf("Failed to create test file: %v", err)
|
|
}
|
|
|
|
// Create existing file in destination with different content
|
|
destFile := filepath.Join(destDir, "test.txt")
|
|
if err := os.WriteFile(destFile, []byte("old content"), 0644); err != nil {
|
|
t.Fatalf("Failed to create dest file: %v", err)
|
|
}
|
|
|
|
// Copy overlay
|
|
err := CopyOverlay(rigDir, destDir)
|
|
if err != nil {
|
|
t.Fatalf("CopyOverlay() error = %v", err)
|
|
}
|
|
|
|
// Verify file was overwritten
|
|
content, err := os.ReadFile(destFile)
|
|
if err != nil {
|
|
t.Fatalf("Failed to read dest file: %v", err)
|
|
}
|
|
if string(content) != "new content" {
|
|
t.Errorf("File content = %q, want %q", string(content), "new content")
|
|
}
|
|
}
|
|
|
|
func TestCopyFilePreserveMode(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
|
|
// Create source file
|
|
srcFile := filepath.Join(tmpDir, "src.txt")
|
|
if err := os.WriteFile(srcFile, []byte("test content"), 0644); err != nil {
|
|
t.Fatalf("Failed to create src file: %v", err)
|
|
}
|
|
|
|
// Copy file
|
|
dstFile := filepath.Join(tmpDir, "dst.txt")
|
|
err := copyFilePreserveMode(srcFile, dstFile)
|
|
if err != nil {
|
|
t.Fatalf("copyFilePreserveMode() error = %v", err)
|
|
}
|
|
|
|
// Verify content
|
|
content, err := os.ReadFile(dstFile)
|
|
if err != nil {
|
|
t.Errorf("Failed to read dst file: %v", err)
|
|
}
|
|
if string(content) != "test content" {
|
|
t.Errorf("Content = %q, want %q", string(content), "test content")
|
|
}
|
|
|
|
// Verify permissions
|
|
srcInfo, _ := os.Stat(srcFile)
|
|
dstInfo, err := os.Stat(dstFile)
|
|
if err != nil {
|
|
t.Fatalf("Failed to stat dst file: %v", err)
|
|
}
|
|
if srcInfo.Mode().Perm() != dstInfo.Mode().Perm() {
|
|
t.Errorf("Permissions not preserved: src=%v, dest=%v", srcInfo.Mode(), dstInfo.Mode())
|
|
}
|
|
}
|
|
|
|
func TestCopyFilePreserveMode_NonexistentSource(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
|
|
srcFile := filepath.Join(tmpDir, "nonexistent.txt")
|
|
dstFile := filepath.Join(tmpDir, "dst.txt")
|
|
|
|
err := copyFilePreserveMode(srcFile, dstFile)
|
|
if err == nil {
|
|
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
|
|
}
|