diff --git a/internal/components/filemerge/writer.go b/internal/components/filemerge/writer.go index 9ed9f3bef..9e198bd4a 100644 --- a/internal/components/filemerge/writer.go +++ b/internal/components/filemerge/writer.go @@ -150,7 +150,17 @@ func ensureAtomicParentDir(dir, path string) error { return fmt.Errorf("stat parent directory for %q: %w", path, err) } if info.Mode()&os.ModeSymlink != 0 { - return fmt.Errorf("refusing symlink parent directory %q for %q", dir, path) + // Parent is a symlink (e.g. ~/.claude/agents → dotfiles repo). + // Resolve the target and continue checks against the real directory. + resolved, err := filepath.EvalSymlinks(dir) + if err != nil { + return fmt.Errorf("resolving symlink parent %q for %q: %w", dir, path, err) + } + info, err = os.Stat(resolved) + if err != nil { + return fmt.Errorf("stat symlink target %q for %q: %w", resolved, path, err) + } + dir = resolved } if !info.IsDir() { return fmt.Errorf("parent path %q for %q is not a directory", dir, path) diff --git a/internal/components/filemerge/writer_test.go b/internal/components/filemerge/writer_test.go index a21f8fbcf..678fc900f 100644 --- a/internal/components/filemerge/writer_test.go +++ b/internal/components/filemerge/writer_test.go @@ -1,6 +1,7 @@ package filemerge import ( + "bytes" "errors" "os" "path/filepath" @@ -125,7 +126,7 @@ func TestWriteFileAtomicRejectsOversizedExistingFile(t *testing.T) { } } -func TestWriteFileAtomicRejectsSymlinkParentDirectory(t *testing.T) { +func TestWriteFileAtomicFollowsSymlinkParentDirectory(t *testing.T) { base := t.TempDir() realDir := filepath.Join(base, "real") if err := os.Mkdir(realDir, 0o755); err != nil { @@ -142,10 +143,23 @@ func TestWriteFileAtomicRejectsSymlinkParentDirectory(t *testing.T) { t.Fatalf("Symlink(linkDir) error = %v", err) } + // Writing through a symlinked parent (e.g. ~/.claude/agents → dotfiles repo) + // must succeed: the file lands in the real directory. + content := []byte("value\n") path := filepath.Join(linkDir, "config.txt") - _, err := WriteFileAtomic(path, []byte("value\n"), 0o644) - if err == nil { - t.Fatal("WriteFileAtomic() error = nil, want symlink parent rejection") + _, err := WriteFileAtomic(path, content, 0o644) + if err != nil { + t.Fatalf("WriteFileAtomic() via symlink parent error = %v, want success", err) + } + + // Verify the file was written to the real directory. + realPath := filepath.Join(realDir, "config.txt") + got, readErr := os.ReadFile(realPath) + if readErr != nil { + t.Fatalf("ReadFile(realPath) error = %v", readErr) + } + if !bytes.Equal(got, content) { + t.Fatalf("content = %q, want %q", got, content) } }