From c7c212f8a100c0b0c2575ae8cd3b059c0762c029 Mon Sep 17 00:00:00 2001 From: Doug Campos Date: Sat, 20 Dec 2025 19:43:43 -0500 Subject: [PATCH] fix(init): handle read-only gitignore gracefully in stealth mode (#663) When the global gitignore file is read-only (e.g., symlink to immutable location), print manual instructions instead of failing with an error. --- cmd/bd/init.go | 14 ++++++- cmd/bd/init_test.go | 89 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 102 insertions(+), 1 deletion(-) diff --git a/cmd/bd/init.go b/cmd/bd/init.go index edc2aa4b..077feeed 100644 --- a/cmd/bd/init.go +++ b/cmd/bd/init.go @@ -1503,7 +1503,19 @@ func setupGlobalGitIgnore(homeDir string, projectPath string, verbose bool) erro // Write the updated ignore file // #nosec G306 - config file needs 0644 if err := os.WriteFile(ignorePath, []byte(newContent), 0644); err != nil { - return fmt.Errorf("failed to write global gitignore: %w", err) + fmt.Printf("\nUnable to write to %s (file is read-only)\n\n", ignorePath) + fmt.Printf("To enable stealth mode, add these lines to your global gitignore:\n\n") + if !hasBeads || !hasClaude { + fmt.Printf("# Beads stealth mode: %s\n", projectPath) + } + if !hasBeads { + fmt.Printf("%s\n", beadsPattern) + } + if !hasClaude { + fmt.Printf("%s\n", claudePattern) + } + fmt.Println() + return nil } if verbose { diff --git a/cmd/bd/init_test.go b/cmd/bd/init_test.go index 4f2b9e40..4f81122e 100644 --- a/cmd/bd/init_test.go +++ b/cmd/bd/init_test.go @@ -1047,3 +1047,92 @@ func TestSetupClaudeSettings_NoExistingFile(t *testing.T) { t.Error("File should contain bd onboard prompt") } } + +// TestSetupGlobalGitIgnore_ReadOnly verifies graceful handling when the +// gitignore file cannot be written (prints manual instructions instead of failing). +func TestSetupGlobalGitIgnore_ReadOnly(t *testing.T) { + t.Run("read-only file", func(t *testing.T) { + tmpDir := t.TempDir() + configDir := filepath.Join(tmpDir, ".config", "git") + if err := os.MkdirAll(configDir, 0755); err != nil { + t.Fatal(err) + } + + ignorePath := filepath.Join(configDir, "ignore") + if err := os.WriteFile(ignorePath, []byte("# existing\n"), 0644); err != nil { + t.Fatal(err) + } + if err := os.Chmod(ignorePath, 0444); err != nil { + t.Fatal(err) + } + defer os.Chmod(ignorePath, 0644) + + output := captureStdout(t, func() error { + return setupGlobalGitIgnore(tmpDir, "/test/project", false) + }) + + if !strings.Contains(output, "Unable to write") { + t.Error("expected instructions for manual addition") + } + if !strings.Contains(output, "/test/project/.beads/") { + t.Error("expected .beads pattern in output") + } + }) + + t.Run("symlink to read-only file", func(t *testing.T) { + tmpDir := t.TempDir() + + // Target file in a separate location + targetDir := filepath.Join(tmpDir, "target") + if err := os.MkdirAll(targetDir, 0755); err != nil { + t.Fatal(err) + } + targetFile := filepath.Join(targetDir, "ignore") + if err := os.WriteFile(targetFile, []byte("# existing\n"), 0644); err != nil { + t.Fatal(err) + } + if err := os.Chmod(targetFile, 0444); err != nil { + t.Fatal(err) + } + defer os.Chmod(targetFile, 0644) + + // Symlink from expected location + configDir := filepath.Join(tmpDir, ".config", "git") + if err := os.MkdirAll(configDir, 0755); err != nil { + t.Fatal(err) + } + if err := os.Symlink(targetFile, filepath.Join(configDir, "ignore")); err != nil { + t.Fatal(err) + } + + output := captureStdout(t, func() error { + return setupGlobalGitIgnore(tmpDir, "/test/project", false) + }) + + if !strings.Contains(output, "Unable to write") { + t.Error("expected instructions for manual addition") + } + if !strings.Contains(output, "/test/project/.beads/") { + t.Error("expected .beads pattern in output") + } + }) +} + +func captureStdout(t *testing.T, fn func() error) string { + t.Helper() + oldStdout := os.Stdout + r, w, _ := os.Pipe() + os.Stdout = w + + err := fn() + + w.Close() + var buf bytes.Buffer + buf.ReadFrom(r) + os.Stdout = oldStdout + + if err != nil { + t.Errorf("unexpected error: %v", err) + } + return buf.String() +}