Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
118 changes: 118 additions & 0 deletions internal/cli/init.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package cli

import (
"bufio"
"context"
"fmt"
"os"
Expand Down Expand Up @@ -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
}

Expand Down Expand Up @@ -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
}
166 changes: 166 additions & 0 deletions internal/cli/init_integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"os"
"os/exec"
"path/filepath"
"strings"
"testing"

"github.com/stretchr/testify/assert"
Expand Down Expand Up @@ -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/"))
})
}
Loading
Loading