From e0e38d8b588bea8620de01c40b9554d281fc1404 Mon Sep 17 00:00:00 2001 From: daniel-lxs Date: Wed, 26 Nov 2025 19:28:57 -0500 Subject: [PATCH] fix: create parent directories early in write_to_file to prevent ENOENT errors Fixes #9634 When creating a file in a non-existent subdirectory, the write_to_file tool would fail with ENOENT errors because directories were only created later when diffViewProvider.open() was called. Changes: - Add createDirectoriesForFile import from utils/fs - Create directories immediately after determining file doesn't exist in both execute() and handlePartial() methods - Add comprehensive tests for directory creation behavior This ensures parent directories are created before any subsequent file operations that depend on them. --- src/core/tools/WriteToFileTool.ts | 21 +++++++-- .../tools/__tests__/writeToFileTool.spec.ts | 46 ++++++++++++++++++- 2 files changed, 62 insertions(+), 5 deletions(-) diff --git a/src/core/tools/WriteToFileTool.ts b/src/core/tools/WriteToFileTool.ts index 475be66c2cb..f7d7d2c6a62 100644 --- a/src/core/tools/WriteToFileTool.ts +++ b/src/core/tools/WriteToFileTool.ts @@ -7,7 +7,7 @@ import { Task } from "../task/Task" import { ClineSayTool } from "../../shared/ExtensionMessage" import { formatResponse } from "../prompts/responses" import { RecordSource } from "../context-tracking/FileContextTrackerTypes" -import { fileExistsAtPath } from "../../utils/fs" +import { fileExistsAtPath, createDirectoriesForFile } from "../../utils/fs" import { stripLineNumbers, everyLineHasLineNumbers } from "../../integrations/misc/extract-text" import { getReadablePath } from "../../utils/path" import { isPathOutsideWorkspace } from "../../utils/pathUtils" @@ -70,15 +70,21 @@ export class WriteToFileTool extends BaseTool<"write_to_file"> { const isWriteProtected = task.rooProtectedController?.isWriteProtected(relPath) || false let fileExists: boolean + const absolutePath = path.resolve(task.cwd, relPath) if (task.diffViewProvider.editType !== undefined) { fileExists = task.diffViewProvider.editType === "modify" } else { - const absolutePath = path.resolve(task.cwd, relPath) fileExists = await fileExistsAtPath(absolutePath) task.diffViewProvider.editType = fileExists ? "modify" : "create" } + // Create parent directories early for new files to prevent ENOENT errors + // in subsequent operations (e.g., diffViewProvider.open, fs.readFile) + if (!fileExists) { + await createDirectoriesForFile(absolutePath) + } + if (newContent.startsWith("```")) { newContent = newContent.split("\n").slice(1).join("\n") } @@ -307,16 +313,23 @@ export class WriteToFileTool extends BaseTool<"write_to_file"> { } let fileExists: boolean + const absolutePath = path.resolve(task.cwd, relPath) + if (task.diffViewProvider.editType !== undefined) { fileExists = task.diffViewProvider.editType === "modify" } else { - const absolutePath = path.resolve(task.cwd, relPath) fileExists = await fileExistsAtPath(absolutePath) task.diffViewProvider.editType = fileExists ? "modify" : "create" } + // Create parent directories early for new files to prevent ENOENT errors + // in subsequent operations (e.g., diffViewProvider.open) + if (!fileExists) { + await createDirectoriesForFile(absolutePath) + } + const isWriteProtected = task.rooProtectedController?.isWriteProtected(relPath) || false - const fullPath = path.resolve(task.cwd, relPath) + const fullPath = absolutePath const isOutsideWorkspace = isPathOutsideWorkspace(fullPath) const sharedMessageProps: ClineSayTool = { diff --git a/src/core/tools/__tests__/writeToFileTool.spec.ts b/src/core/tools/__tests__/writeToFileTool.spec.ts index 714269795ee..589806a617d 100644 --- a/src/core/tools/__tests__/writeToFileTool.spec.ts +++ b/src/core/tools/__tests__/writeToFileTool.spec.ts @@ -2,7 +2,7 @@ import * as path from "path" import type { MockedFunction } from "vitest" -import { fileExistsAtPath } from "../../../utils/fs" +import { fileExistsAtPath, createDirectoriesForFile } from "../../../utils/fs" import { detectCodeOmission } from "../../../integrations/editor/detect-omission" import { isPathOutsideWorkspace } from "../../../utils/pathUtils" import { getReadablePath } from "../../../utils/path" @@ -29,6 +29,7 @@ vi.mock("delay", () => ({ vi.mock("../../../utils/fs", () => ({ fileExistsAtPath: vi.fn().mockResolvedValue(false), + createDirectoriesForFile: vi.fn().mockResolvedValue([]), })) vi.mock("../../prompts/responses", () => ({ @@ -101,6 +102,7 @@ describe("writeToFileTool", () => { // Mocked functions with correct types const mockedFileExistsAtPath = fileExistsAtPath as MockedFunction + const mockedCreateDirectoriesForFile = createDirectoriesForFile as MockedFunction const mockedDetectCodeOmission = detectCodeOmission as MockedFunction const mockedIsPathOutsideWorkspace = isPathOutsideWorkspace as MockedFunction const mockedGetReadablePath = getReadablePath as MockedFunction @@ -276,6 +278,48 @@ describe("writeToFileTool", () => { }) }) + describe("directory creation for new files", () => { + it.skipIf(process.platform === "win32")( + "creates parent directories early when file does not exist (execute)", + async () => { + await executeWriteFileTool({}, { fileExists: false }) + + expect(mockedCreateDirectoriesForFile).toHaveBeenCalledWith(absoluteFilePath) + }, + ) + + it.skipIf(process.platform === "win32")( + "creates parent directories early when file does not exist (partial)", + async () => { + await executeWriteFileTool({}, { fileExists: false, isPartial: true }) + + expect(mockedCreateDirectoriesForFile).toHaveBeenCalledWith(absoluteFilePath) + }, + ) + + it("does not create directories when file exists", async () => { + await executeWriteFileTool({}, { fileExists: true }) + + expect(mockedCreateDirectoriesForFile).not.toHaveBeenCalled() + }) + + it("does not create directories when editType is cached as modify", async () => { + mockCline.diffViewProvider.editType = "modify" + + await executeWriteFileTool({}) + + expect(mockedCreateDirectoriesForFile).not.toHaveBeenCalled() + }) + + it.skipIf(process.platform === "win32")("creates directories when editType is cached as create", async () => { + mockCline.diffViewProvider.editType = "create" + + await executeWriteFileTool({}) + + expect(mockedCreateDirectoriesForFile).toHaveBeenCalledWith(absoluteFilePath) + }) + }) + describe("content preprocessing", () => { it("removes markdown code block markers from content", async () => { await executeWriteFileTool({ content: testContentWithMarkdown })