diff --git a/internal/cli/init.go b/internal/cli/init.go index 76332ca..bd5aca6 100644 --- a/internal/cli/init.go +++ b/internal/cli/init.go @@ -1,6 +1,7 @@ package cli import ( + "bufio" "context" "fmt" "os" @@ -126,6 +127,13 @@ func runInitWithGhCommand(ctx context.Context, _ []string, ghCmd GhCommandInterf log.Warn(ctx, "Failed to copy Claude command templates", logging.Field{Key: "error", Value: err.Error()}) } + // Ensure .gitignore entries for soba dynamic files + gitignorePath := filepath.Join(currentDir, ".gitignore") + if err := ensureGitignoreEntries(gitignorePath); err != nil { + // Log the error as warning but don't fail the init command + log.Warn(ctx, "Failed to update .gitignore", logging.Field{Key: "error", Value: err.Error()}) + } + return nil } @@ -217,3 +225,113 @@ func copyClaudeCommandTemplates() error { return nil } + +// ensureGitignoreEntries ensures .soba/soba.pid and .soba/logs/ are in .gitignore +func ensureGitignoreEntries(gitignorePath string) error { + entries := []string{".soba/soba.pid", ".soba/logs/"} + + existingLines, fileExists, err := readGitignoreFile(gitignorePath) + if err != nil { + return err + } + + linesToAdd := findMissingEntries(existingLines, entries) + if len(linesToAdd) == 0 { + return nil + } + + content := buildGitignoreContent(existingLines, linesToAdd, fileExists) + return writeGitignoreFile(gitignorePath, content) +} + +// readGitignoreFile reads the .gitignore file and returns its lines and existence status +func readGitignoreFile(gitignorePath string) ([]string, bool, error) { + file, err := os.Open(gitignorePath) + if err != nil { + if os.IsNotExist(err) { + return nil, false, nil + } + if os.IsPermission(err) { + return nil, false, errors.WrapInternal(err, "permission denied: cannot read .gitignore") + } + return nil, false, errors.WrapInternal(err, "failed to open .gitignore") + } + defer file.Close() + + var existingLines []string // nolint:prealloc + scanner := bufio.NewScanner(file) + for scanner.Scan() { + existingLines = append(existingLines, scanner.Text()) + } + if err := scanner.Err(); err != nil { + return nil, false, errors.WrapInternal(err, "failed to read .gitignore") + } + + return existingLines, true, nil +} + +// findMissingEntries returns entries that don't exist in the current lines +func findMissingEntries(existingLines, entries []string) []string { + linesToAdd := make([]string, 0, len(entries)) + for _, entry := range entries { + found := false + for _, line := range existingLines { + if strings.TrimSpace(line) == entry { + found = true + break + } + } + if !found { + linesToAdd = append(linesToAdd, entry) + } + } + return linesToAdd +} + +// buildGitignoreContent builds the complete .gitignore content +func buildGitignoreContent(existingLines, linesToAdd []string, fileExists bool) string { + var content strings.Builder + + if fileExists { + for _, line := range existingLines { + content.WriteString(line) + content.WriteString("\n") + } + } + + if len(linesToAdd) > 0 { + addSobaSection(&content, existingLines, linesToAdd, fileExists) + } + + return content.String() +} + +// addSobaSection adds the soba section to the gitignore content +func addSobaSection(content *strings.Builder, existingLines, linesToAdd []string, fileExists bool) { + if fileExists && len(existingLines) > 0 { + lastLine := "" + if len(existingLines) > 0 { + lastLine = existingLines[len(existingLines)-1] + } + if lastLine != "" { + content.WriteString("\n") + } + } + + content.WriteString("# Soba generated files\n") + for _, entry := range linesToAdd { + content.WriteString(entry) + content.WriteString("\n") + } +} + +// writeGitignoreFile writes the content to the .gitignore file +func writeGitignoreFile(gitignorePath, content string) error { + if err := os.WriteFile(gitignorePath, []byte(content), 0600); err != nil { + if os.IsPermission(err) { + return errors.WrapInternal(err, "permission denied: cannot write .gitignore") + } + return errors.WrapInternal(err, "failed to write .gitignore") + } + return nil +} diff --git a/internal/cli/init_integration_test.go b/internal/cli/init_integration_test.go index 62dba15..0cb41a5 100644 --- a/internal/cli/init_integration_test.go +++ b/internal/cli/init_integration_test.go @@ -5,6 +5,7 @@ import ( "os" "os/exec" "path/filepath" + "strings" "testing" "github.com/stretchr/testify/assert" @@ -280,4 +281,169 @@ func TestInitWithGitRepository(t *testing.T) { configPath := filepath.Join(tempDir, ".soba", "config.yml") assert.NoFileExists(t, configPath) }) + + t.Run("should create .gitignore with soba entries on init", func(t *testing.T) { + // Setup: Create a temporary directory with git repository + tempDir := t.TempDir() + oldDir, _ := os.Getwd() + defer os.Chdir(oldDir) + require.NoError(t, os.Chdir(tempDir)) + + // Initialize git repository + cmd := exec.Command("git", "init") + output, err := cmd.CombinedOutput() + require.NoError(t, err, "Failed to init git repository: %s", string(output)) + + // Configure git user for CI environment + cmd = exec.Command("git", "config", "user.email", "test@example.com") + output, err = cmd.CombinedOutput() + require.NoError(t, err, "Failed to configure git user.email: %s", string(output)) + + cmd = exec.Command("git", "config", "user.name", "Test User") + output, err = cmd.CombinedOutput() + require.NoError(t, err, "Failed to configure git user.name: %s", string(output)) + + // Add remote + cmd = exec.Command("git", "remote", "add", "origin", "https://github.com/test-owner/test-repo.git") + output, err = cmd.CombinedOutput() + require.NoError(t, err, "Failed to add remote: %s", string(output)) + + // Execute init + mockGhCmd := &MockGhCommand{ + available: true, + authenticated: true, + created: 11, + skipped: 0, + } + err = runInitWithGhCommand(context.Background(), []string{}, mockGhCmd) + require.NoError(t, err) + + // Verify .gitignore was created with correct entries + gitignorePath := filepath.Join(tempDir, ".gitignore") + assert.FileExists(t, gitignorePath) + + content, err := os.ReadFile(gitignorePath) + require.NoError(t, err) + contentStr := string(content) + + assert.Contains(t, contentStr, ".soba/soba.pid") + assert.Contains(t, contentStr, ".soba/logs/") + assert.Contains(t, contentStr, "# Soba generated files") + }) + + t.Run("should append to existing .gitignore on init", func(t *testing.T) { + // Setup: Create a temporary directory with git repository + tempDir := t.TempDir() + oldDir, _ := os.Getwd() + defer os.Chdir(oldDir) + require.NoError(t, os.Chdir(tempDir)) + + // Create existing .gitignore + existingContent := `# Existing gitignore +node_modules/ +dist/ +*.log +` + gitignorePath := filepath.Join(tempDir, ".gitignore") + require.NoError(t, os.WriteFile(gitignorePath, []byte(existingContent), 0644)) + + // Initialize git repository + cmd := exec.Command("git", "init") + output, err := cmd.CombinedOutput() + require.NoError(t, err, "Failed to init git repository: %s", string(output)) + + // Configure git user for CI environment + cmd = exec.Command("git", "config", "user.email", "test@example.com") + output, err = cmd.CombinedOutput() + require.NoError(t, err, "Failed to configure git user.email: %s", string(output)) + + cmd = exec.Command("git", "config", "user.name", "Test User") + output, err = cmd.CombinedOutput() + require.NoError(t, err, "Failed to configure git user.name: %s", string(output)) + + // Add remote + cmd = exec.Command("git", "remote", "add", "origin", "https://github.com/test-owner/test-repo.git") + output, err = cmd.CombinedOutput() + require.NoError(t, err, "Failed to add remote: %s", string(output)) + + // Execute init + mockGhCmd := &MockGhCommand{ + available: true, + authenticated: true, + created: 11, + skipped: 0, + } + err = runInitWithGhCommand(context.Background(), []string{}, mockGhCmd) + require.NoError(t, err) + + // Verify .gitignore contains both existing and new entries + content, err := os.ReadFile(gitignorePath) + require.NoError(t, err) + contentStr := string(content) + + // Check existing entries are preserved + assert.Contains(t, contentStr, "node_modules/") + assert.Contains(t, contentStr, "dist/") + assert.Contains(t, contentStr, "*.log") + + // Check new entries are added + assert.Contains(t, contentStr, ".soba/soba.pid") + assert.Contains(t, contentStr, ".soba/logs/") + assert.Contains(t, contentStr, "# Soba generated files") + }) + + t.Run("should not duplicate .gitignore entries on multiple init calls", func(t *testing.T) { + // Setup: Create a temporary directory with git repository + tempDir := t.TempDir() + oldDir, _ := os.Getwd() + defer os.Chdir(oldDir) + require.NoError(t, os.Chdir(tempDir)) + + // Initialize git repository + cmd := exec.Command("git", "init") + output, err := cmd.CombinedOutput() + require.NoError(t, err, "Failed to init git repository: %s", string(output)) + + // Configure git user for CI environment + cmd = exec.Command("git", "config", "user.email", "test@example.com") + output, err = cmd.CombinedOutput() + require.NoError(t, err, "Failed to configure git user.email: %s", string(output)) + + cmd = exec.Command("git", "config", "user.name", "Test User") + output, err = cmd.CombinedOutput() + require.NoError(t, err, "Failed to configure git user.name: %s", string(output)) + + // Add remote + cmd = exec.Command("git", "remote", "add", "origin", "https://github.com/test-owner/test-repo.git") + output, err = cmd.CombinedOutput() + require.NoError(t, err, "Failed to add remote: %s", string(output)) + + // Execute init first time + mockGhCmd := &MockGhCommand{ + available: true, + authenticated: true, + created: 11, + skipped: 0, + } + err = runInitWithGhCommand(context.Background(), []string{}, mockGhCmd) + require.NoError(t, err) + + // Delete the config file to allow second init + configPath := filepath.Join(tempDir, ".soba", "config.yml") + require.NoError(t, os.Remove(configPath)) + + // Execute init second time + err = runInitWithGhCommand(context.Background(), []string{}, mockGhCmd) + require.NoError(t, err) + + // Verify .gitignore doesn't have duplicate entries + gitignorePath := filepath.Join(tempDir, ".gitignore") + content, err := os.ReadFile(gitignorePath) + require.NoError(t, err) + contentStr := string(content) + + // Count occurrences - should be exactly 1 each + assert.Equal(t, 1, strings.Count(contentStr, ".soba/soba.pid")) + assert.Equal(t, 1, strings.Count(contentStr, ".soba/logs/")) + }) } diff --git a/internal/cli/init_test.go b/internal/cli/init_test.go index 0839700..7aa8c64 100644 --- a/internal/cli/init_test.go +++ b/internal/cli/init_test.go @@ -6,6 +6,7 @@ import ( "os" "os/exec" "path/filepath" + "strings" "testing" "github.com/stretchr/testify/assert" @@ -408,6 +409,198 @@ func (m *MockGhCommand) CreateSobaLabels(ctx context.Context, owner, repo string return m.created, m.skipped, nil } +func TestEnsureGitignoreEntries(t *testing.T) { + t.Run("should create new .gitignore with entries", func(t *testing.T) { + // Setup + tempDir := t.TempDir() + gitignorePath := filepath.Join(tempDir, ".gitignore") + + // Execute + err := ensureGitignoreEntries(gitignorePath) + + // Assert + assert.NoError(t, err) + assert.FileExists(t, gitignorePath) + + content, err := os.ReadFile(gitignorePath) + require.NoError(t, err) + assert.Contains(t, string(content), ".soba/soba.pid") + assert.Contains(t, string(content), ".soba/logs/") + }) + + t.Run("should add entries to existing .gitignore", func(t *testing.T) { + // Setup + tempDir := t.TempDir() + gitignorePath := filepath.Join(tempDir, ".gitignore") + + existingContent := `# Existing entries +node_modules/ +*.log +dist/ +` + require.NoError(t, os.WriteFile(gitignorePath, []byte(existingContent), 0644)) + + // Execute + err := ensureGitignoreEntries(gitignorePath) + + // Assert + assert.NoError(t, err) + + content, err := os.ReadFile(gitignorePath) + require.NoError(t, err) + contentStr := string(content) + + // Verify existing content is preserved + assert.Contains(t, contentStr, "node_modules/") + assert.Contains(t, contentStr, "*.log") + assert.Contains(t, contentStr, "dist/") + + // Verify new entries are added + assert.Contains(t, contentStr, ".soba/soba.pid") + assert.Contains(t, contentStr, ".soba/logs/") + }) + + t.Run("should not add duplicate entries", func(t *testing.T) { + // Setup + tempDir := t.TempDir() + gitignorePath := filepath.Join(tempDir, ".gitignore") + + existingContent := `# Existing entries +.soba/soba.pid +.soba/logs/ +node_modules/ +` + require.NoError(t, os.WriteFile(gitignorePath, []byte(existingContent), 0644)) + + // Execute + err := ensureGitignoreEntries(gitignorePath) + + // Assert + assert.NoError(t, err) + + content, err := os.ReadFile(gitignorePath) + require.NoError(t, err) + contentStr := string(content) + + // Count occurrences + assert.Equal(t, 1, strings.Count(contentStr, ".soba/soba.pid")) + assert.Equal(t, 1, strings.Count(contentStr, ".soba/logs/")) + }) + + t.Run("should add missing entries when only one exists", func(t *testing.T) { + // Setup + tempDir := t.TempDir() + gitignorePath := filepath.Join(tempDir, ".gitignore") + + existingContent := `# Existing entries +.soba/soba.pid +node_modules/ +` + require.NoError(t, os.WriteFile(gitignorePath, []byte(existingContent), 0644)) + + // Execute + err := ensureGitignoreEntries(gitignorePath) + + // Assert + assert.NoError(t, err) + + content, err := os.ReadFile(gitignorePath) + require.NoError(t, err) + contentStr := string(content) + + // Verify both entries exist + assert.Contains(t, contentStr, ".soba/soba.pid") + assert.Contains(t, contentStr, ".soba/logs/") + + // Verify no duplicates + assert.Equal(t, 1, strings.Count(contentStr, ".soba/soba.pid")) + }) + + t.Run("should handle permission errors", func(t *testing.T) { + // Skip if running as root + if os.Geteuid() == 0 { + t.Skip("Test cannot run as root") + } + + // Setup + tempDir := t.TempDir() + gitignorePath := filepath.Join(tempDir, ".gitignore") + + // Create file with read-only permission + require.NoError(t, os.WriteFile(gitignorePath, []byte("existing content\n"), 0444)) + defer os.Chmod(gitignorePath, 0644) // Restore permission for cleanup + + // Execute + err := ensureGitignoreEntries(gitignorePath) + + // Assert - should return error + assert.Error(t, err) + assert.Contains(t, err.Error(), "permission") + }) + + t.Run("should handle directory permission errors", func(t *testing.T) { + // Skip if running as root + if os.Geteuid() == 0 { + t.Skip("Test cannot run as root") + } + + // Setup + tempDir := t.TempDir() + + // Create directory with no write permission + require.NoError(t, os.Chmod(tempDir, 0555)) + defer os.Chmod(tempDir, 0755) // Restore permission for cleanup + + gitignorePath := filepath.Join(tempDir, ".gitignore") + + // Execute + err := ensureGitignoreEntries(gitignorePath) + + // Assert - should return error + assert.Error(t, err) + }) + + t.Run("should add soba section header", func(t *testing.T) { + // Setup + tempDir := t.TempDir() + gitignorePath := filepath.Join(tempDir, ".gitignore") + + // Execute + err := ensureGitignoreEntries(gitignorePath) + + // Assert + assert.NoError(t, err) + + content, err := os.ReadFile(gitignorePath) + require.NoError(t, err) + contentStr := string(content) + + // Verify soba section header is added + assert.Contains(t, contentStr, "# Soba generated files") + }) + + t.Run("should preserve trailing newline", func(t *testing.T) { + // Setup + tempDir := t.TempDir() + gitignorePath := filepath.Join(tempDir, ".gitignore") + + existingContent := "node_modules/\n" + require.NoError(t, os.WriteFile(gitignorePath, []byte(existingContent), 0644)) + + // Execute + err := ensureGitignoreEntries(gitignorePath) + + // Assert + assert.NoError(t, err) + + content, err := os.ReadFile(gitignorePath) + require.NoError(t, err) + + // Verify file ends with newline + assert.True(t, strings.HasSuffix(string(content), "\n")) + }) +} + func TestCopyClaudeCommandTemplates(t *testing.T) { t.Run("should copy embedded template files to target directory", func(t *testing.T) { // Setup