Merge PR #665: fix(setup): preserve symlinks in atomicWriteFile

Add ResolveForWrite helper that resolves symlinks before writing, so
atomic writes go to the symlink target instead of replacing the symlink.

This prevents `bd setup claude` from overwriting nix/home-manager
managed ~/.claude/settings.json symlinks.

Closes #665

Co-authored-by: qmx <qmx@qmx.me>
This commit is contained in:
Steve Yegge
2025-12-20 18:03:12 -08:00
5 changed files with 409 additions and 292 deletions

View File

@@ -4,12 +4,20 @@ import (
"fmt"
"os"
"path/filepath"
"github.com/steveyegge/beads/internal/utils"
)
// atomicWriteFile writes data to a file atomically using a unique temporary file.
// This prevents race conditions when multiple processes write to the same file.
// If path is a symlink, writes to the resolved target (preserving the symlink).
func atomicWriteFile(path string, data []byte) error {
dir := filepath.Dir(path)
targetPath, err := utils.ResolveForWrite(path)
if err != nil {
return fmt.Errorf("resolve path: %w", err)
}
dir := filepath.Dir(targetPath)
// Create unique temp file in same directory
tmpFile, err := os.CreateTemp(dir, ".*.tmp")
@@ -38,7 +46,7 @@ func atomicWriteFile(path string, data []byte) error {
}
// Atomic rename
if err := os.Rename(tmpPath, path); err != nil {
if err := os.Rename(tmpPath, targetPath); err != nil {
_ = os.Remove(tmpPath) // Best effort cleanup
return fmt.Errorf("rename temp file: %w", err)
}

View File

@@ -67,6 +67,45 @@ func TestAtomicWriteFile(t *testing.T) {
}
}
func TestAtomicWriteFile_PreservesSymlink(t *testing.T) {
tmpDir := t.TempDir()
// Create target file
target := filepath.Join(tmpDir, "target.txt")
if err := os.WriteFile(target, []byte("original"), 0644); err != nil {
t.Fatal(err)
}
// Create symlink
link := filepath.Join(tmpDir, "link.txt")
if err := os.Symlink(target, link); err != nil {
t.Fatal(err)
}
// Write via symlink
if err := atomicWriteFile(link, []byte("updated")); err != nil {
t.Fatalf("atomicWriteFile failed: %v", err)
}
// Verify symlink still exists
info, err := os.Lstat(link)
if err != nil {
t.Fatalf("failed to lstat link: %v", err)
}
if info.Mode()&os.ModeSymlink == 0 {
t.Error("symlink was replaced with regular file")
}
// Verify target was updated
data, err := os.ReadFile(target)
if err != nil {
t.Fatalf("failed to read target: %v", err)
}
if string(data) != "updated" {
t.Errorf("target content = %q, want %q", string(data), "updated")
}
}
func TestDirExists(t *testing.T) {
tmpDir := t.TempDir()