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.
This commit is contained in:
Doug Campos
2025-12-20 19:43:43 -05:00
committed by GitHub
parent 5b2a516aca
commit c7c212f8a1
2 changed files with 102 additions and 1 deletions

View File

@@ -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 {

View File

@@ -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()
}