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:
@@ -1503,7 +1503,19 @@ func setupGlobalGitIgnore(homeDir string, projectPath string, verbose bool) erro
|
|||||||
// Write the updated ignore file
|
// Write the updated ignore file
|
||||||
// #nosec G306 - config file needs 0644
|
// #nosec G306 - config file needs 0644
|
||||||
if err := os.WriteFile(ignorePath, []byte(newContent), 0644); err != nil {
|
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 {
|
if verbose {
|
||||||
|
|||||||
@@ -1047,3 +1047,92 @@ func TestSetupClaudeSettings_NoExistingFile(t *testing.T) {
|
|||||||
t.Error("File should contain bd onboard prompt")
|
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()
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user