Skip to content

Commit e0e38d8

Browse files
committed
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.
1 parent ba09228 commit e0e38d8

File tree

2 files changed

+62
-5
lines changed

2 files changed

+62
-5
lines changed

src/core/tools/WriteToFileTool.ts

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import { Task } from "../task/Task"
77
import { ClineSayTool } from "../../shared/ExtensionMessage"
88
import { formatResponse } from "../prompts/responses"
99
import { RecordSource } from "../context-tracking/FileContextTrackerTypes"
10-
import { fileExistsAtPath } from "../../utils/fs"
10+
import { fileExistsAtPath, createDirectoriesForFile } from "../../utils/fs"
1111
import { stripLineNumbers, everyLineHasLineNumbers } from "../../integrations/misc/extract-text"
1212
import { getReadablePath } from "../../utils/path"
1313
import { isPathOutsideWorkspace } from "../../utils/pathUtils"
@@ -70,15 +70,21 @@ export class WriteToFileTool extends BaseTool<"write_to_file"> {
7070
const isWriteProtected = task.rooProtectedController?.isWriteProtected(relPath) || false
7171

7272
let fileExists: boolean
73+
const absolutePath = path.resolve(task.cwd, relPath)
7374

7475
if (task.diffViewProvider.editType !== undefined) {
7576
fileExists = task.diffViewProvider.editType === "modify"
7677
} else {
77-
const absolutePath = path.resolve(task.cwd, relPath)
7878
fileExists = await fileExistsAtPath(absolutePath)
7979
task.diffViewProvider.editType = fileExists ? "modify" : "create"
8080
}
8181

82+
// Create parent directories early for new files to prevent ENOENT errors
83+
// in subsequent operations (e.g., diffViewProvider.open, fs.readFile)
84+
if (!fileExists) {
85+
await createDirectoriesForFile(absolutePath)
86+
}
87+
8288
if (newContent.startsWith("```")) {
8389
newContent = newContent.split("\n").slice(1).join("\n")
8490
}
@@ -307,16 +313,23 @@ export class WriteToFileTool extends BaseTool<"write_to_file"> {
307313
}
308314

309315
let fileExists: boolean
316+
const absolutePath = path.resolve(task.cwd, relPath)
317+
310318
if (task.diffViewProvider.editType !== undefined) {
311319
fileExists = task.diffViewProvider.editType === "modify"
312320
} else {
313-
const absolutePath = path.resolve(task.cwd, relPath)
314321
fileExists = await fileExistsAtPath(absolutePath)
315322
task.diffViewProvider.editType = fileExists ? "modify" : "create"
316323
}
317324

325+
// Create parent directories early for new files to prevent ENOENT errors
326+
// in subsequent operations (e.g., diffViewProvider.open)
327+
if (!fileExists) {
328+
await createDirectoriesForFile(absolutePath)
329+
}
330+
318331
const isWriteProtected = task.rooProtectedController?.isWriteProtected(relPath) || false
319-
const fullPath = path.resolve(task.cwd, relPath)
332+
const fullPath = absolutePath
320333
const isOutsideWorkspace = isPathOutsideWorkspace(fullPath)
321334

322335
const sharedMessageProps: ClineSayTool = {

src/core/tools/__tests__/writeToFileTool.spec.ts

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import * as path from "path"
22

33
import type { MockedFunction } from "vitest"
44

5-
import { fileExistsAtPath } from "../../../utils/fs"
5+
import { fileExistsAtPath, createDirectoriesForFile } from "../../../utils/fs"
66
import { detectCodeOmission } from "../../../integrations/editor/detect-omission"
77
import { isPathOutsideWorkspace } from "../../../utils/pathUtils"
88
import { getReadablePath } from "../../../utils/path"
@@ -29,6 +29,7 @@ vi.mock("delay", () => ({
2929

3030
vi.mock("../../../utils/fs", () => ({
3131
fileExistsAtPath: vi.fn().mockResolvedValue(false),
32+
createDirectoriesForFile: vi.fn().mockResolvedValue([]),
3233
}))
3334

3435
vi.mock("../../prompts/responses", () => ({
@@ -101,6 +102,7 @@ describe("writeToFileTool", () => {
101102

102103
// Mocked functions with correct types
103104
const mockedFileExistsAtPath = fileExistsAtPath as MockedFunction<typeof fileExistsAtPath>
105+
const mockedCreateDirectoriesForFile = createDirectoriesForFile as MockedFunction<typeof createDirectoriesForFile>
104106
const mockedDetectCodeOmission = detectCodeOmission as MockedFunction<typeof detectCodeOmission>
105107
const mockedIsPathOutsideWorkspace = isPathOutsideWorkspace as MockedFunction<typeof isPathOutsideWorkspace>
106108
const mockedGetReadablePath = getReadablePath as MockedFunction<typeof getReadablePath>
@@ -276,6 +278,48 @@ describe("writeToFileTool", () => {
276278
})
277279
})
278280

281+
describe("directory creation for new files", () => {
282+
it.skipIf(process.platform === "win32")(
283+
"creates parent directories early when file does not exist (execute)",
284+
async () => {
285+
await executeWriteFileTool({}, { fileExists: false })
286+
287+
expect(mockedCreateDirectoriesForFile).toHaveBeenCalledWith(absoluteFilePath)
288+
},
289+
)
290+
291+
it.skipIf(process.platform === "win32")(
292+
"creates parent directories early when file does not exist (partial)",
293+
async () => {
294+
await executeWriteFileTool({}, { fileExists: false, isPartial: true })
295+
296+
expect(mockedCreateDirectoriesForFile).toHaveBeenCalledWith(absoluteFilePath)
297+
},
298+
)
299+
300+
it("does not create directories when file exists", async () => {
301+
await executeWriteFileTool({}, { fileExists: true })
302+
303+
expect(mockedCreateDirectoriesForFile).not.toHaveBeenCalled()
304+
})
305+
306+
it("does not create directories when editType is cached as modify", async () => {
307+
mockCline.diffViewProvider.editType = "modify"
308+
309+
await executeWriteFileTool({})
310+
311+
expect(mockedCreateDirectoriesForFile).not.toHaveBeenCalled()
312+
})
313+
314+
it.skipIf(process.platform === "win32")("creates directories when editType is cached as create", async () => {
315+
mockCline.diffViewProvider.editType = "create"
316+
317+
await executeWriteFileTool({})
318+
319+
expect(mockedCreateDirectoriesForFile).toHaveBeenCalledWith(absoluteFilePath)
320+
})
321+
})
322+
279323
describe("content preprocessing", () => {
280324
it("removes markdown code block markers from content", async () => {
281325
await executeWriteFileTool({ content: testContentWithMarkdown })

0 commit comments

Comments
 (0)