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:
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user