Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 17 additions & 4 deletions src/core/tools/WriteToFileTool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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")
}
Expand Down Expand Up @@ -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 = {
Expand Down
46 changes: 45 additions & 1 deletion src/core/tools/__tests__/writeToFileTool.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -29,6 +29,7 @@ vi.mock("delay", () => ({

vi.mock("../../../utils/fs", () => ({
fileExistsAtPath: vi.fn().mockResolvedValue(false),
createDirectoriesForFile: vi.fn().mockResolvedValue([]),
}))

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

// Mocked functions with correct types
const mockedFileExistsAtPath = fileExistsAtPath as MockedFunction<typeof fileExistsAtPath>
const mockedCreateDirectoriesForFile = createDirectoriesForFile as MockedFunction<typeof createDirectoriesForFile>
const mockedDetectCodeOmission = detectCodeOmission as MockedFunction<typeof detectCodeOmission>
const mockedIsPathOutsideWorkspace = isPathOutsideWorkspace as MockedFunction<typeof isPathOutsideWorkspace>
const mockedGetReadablePath = getReadablePath as MockedFunction<typeof getReadablePath>
Expand Down Expand Up @@ -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 })
Expand Down
Loading