From 9d6e64d94070c530285cb5622d85d40e159e3bcd Mon Sep 17 00:00:00 2001 From: Fermin Facchin Date: Mon, 1 Jun 2026 15:08:40 -0300 Subject: [PATCH] fix(filemerge): follow symlinked parent directories instead of refusing When a parent directory in the write path is a symlink (e.g. ~/.claude/agents pointing to a dotfiles repo), resolve it with filepath.EvalSymlinks and continue checks against the real target directory. Previously, any symlinked parent caused an unconditional refusal, breaking installations for users who manage their Claude Code config via symlinks. --- internal/components/filemerge/writer.go | 12 ++++++++++- internal/components/filemerge/writer_test.go | 22 ++++++++++++++++---- 2 files changed, 29 insertions(+), 5 deletions(-) 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) } }