Skip to content

apply_patch turns CRLF files into mixed line endings #45926

Open

Description

@skyzhao1223

Describe the bug

apply_patch has no CRLF handling. Patch.derive splits the file on \n, so a CRLF file's lines keep their trailing \r. The model supplies LF context lines (it only ever sees LF from read), so exact matching fails and seek falls back to rstrip (trimEnd()), which matches by stripping the \r. The hunk is then spliced back using the model's LF lines, replacing the CRLF line endings only on the touched lines.

Result: a consistently-CRLF file becomes a mixed-ending file after any update.

Repro: derive("f", [{ oldLines: ["foo"], newLines: ["FOO"] }], "foo\r\nbar\r\n") → "FOO\nbar\r\n".

This is the apply_patch counterpart of the edit mixed-line-ending issue (#45880); the edit fix (#45888) did not cover the patch/apply_patch path.

Suggested fix

Detect the file's dominant line ending and preserve per-line endings when splicing the hunk (or normalize the touched region consistently), mirroring the edit tool.

Environment

  • opencode version: latest dev

Activity

  1. github-actions commented on Aug 28, 2026

    @github-actions
    Contributor

    This issue might be a duplicate of existing issues. Please check:

    The root cause described there overlaps significantly with this report. It may be worth coordinating or referencing that issue.

  2. pengzh1 commented on Aug 28, 2026

    @pengzh1

    Taking this — will preserve each line's original ending when splicing hunks in the patch path (LF context from read shouldn't rewrite CRLF lines), with a unit test on the exact derive repro.

  3. Mac-Moneysac commented on Oct 22, 2026

    @Mac-Moneysac

    Hi, I workarounded it via a plugin which I've written: https://github.com/Mac-Moneysac/opencode-fix-line-endings

    It preserves line endings. If it is a new file, platform native line endings are used. It is also available via npm. However, it is still a workaround.

  4. opencodejr commented on Oct 28, 2026

    @opencodejr
    Contributor

    Thanks for the clear report. I reproduced this on 2.2.0, and it still happens on the latest dev (506ac20c).

    What I did:

    • A simulated model read a 4-line CRLF file app.ts.
    • It used the patch tool to change one line and add one.
    • It then ran cat -A app.ts.

    Every line inside the hunk, including the unchanged context lines, came out LF ($). The untouched last line kept CRLF (^M$), so the file ends up with mixed line endings.

    Your unit-level example also behaves as you describe on dev: Patch.derive("f", [{ oldLines: ["foo"], newLines: ["FOO"] }], "foo\r\nbar\r\n") returns "FOO\nbar\r\n".

    The recording and screenshots below show the patch and the cat -A output, and the script below reproduces it. On dev the script may exit with a timeout at its final wait, even though the screen shows the same result.

    Related: #54605 (the same problem on Windows with v2.0.26, closed as a duplicate of this issue), and the open PRs #56128 and #58324, which both try to fix it.

    recording.mp4

    Screenshot

    Screenshot

    Reproduction script (drive.ts)
    // Repro for anomalyco/opencode#45926: the patch tool writes LF line endings into a CRLF file.
    // The simulated model patches one line of a CRLF file, then runs `cat -A app.ts`
    // (CRLF lines end in "^M$", LF lines in "$") so the result shows on screen.
    import { Effect, Stream } from "effect"
    import { Llm, OpenCodeDriver } from "opencode-drive"
    
    const patchText = [
      "*** Begin Patch",
      "*** Update File: app.ts",
      "@@",
      " const a = 1",
      "-const b = 2",
      "+const b = 20",
      "+const extra = 3",
      " const c = 3",
      "*** End Patch",
    ].join("\n")
    
    export default OpenCodeDriver.use(
      {
        ...(process.env.OC_DEV ? { opencode: { dev: process.env.OC_DEV } } : {}),
        tui: { recording: true },
        project: {
          git: true,
          files: { "app.ts": "const a = 1\r\nconst b = 2\r\nconst c = 3\r\nconst d = 4\r\n" },
        },
      },
      ({ ui, llm }) =>
        Effect.gen(function* () {
          const responses = [
            [Llm.toolCall({ index: 0, id: "call_read", name: "read", input: { path: "app.ts" } }), Llm.finish("tool-calls")],
            [Llm.toolCall({ index: 0, id: "call_patch", name: "patch", input: { patchText } }), Llm.finish("tool-calls")],
            [
              Llm.toolCall({ index: 0, id: "call_cat", name: "shell", input: { command: "cat -A app.ts", description: "Show line endings" } }),
              Llm.finish("tool-calls"),
            ],
            [Llm.text("Lines ending in ^M$ are CRLF, lines ending in $ are LF.")],
          ]
          yield* llm.serve((_request, index) => Stream.make(...(responses[index] ?? [Llm.text("end")])))
          yield* ui.submit("Change b to 20 in app.ts and add a line, then show line endings")
          yield* ui.waitFor("are CRLF", { timeout: 60000 })
          yield* Effect.sleep(1500)
          yield* ui.screenshot("after-patch")
        }),
    )

    Run it with opencode-drive: opencode-drive run ./drive.ts. It drives opencode from your PATH; set OC_DEV to the path of an OpenCode checkout to run that from source instead.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions