Gitattributes for Cursor Diffs: 13 Steps, 35 Min [2026]

You ask Cursor’s agent to change one constant. The review pane comes back red and green from the first line to the last, and your pull request now touches 400 lines it never needed to touch. Most developers read that as a full-file rewrite by the model. In many such cases the cause is duller: a line-ending flip that a gitattributes file fixes at the repository level. Cursor Workshop’s team conventions guide describes the agent as a tool that edits files inside a boundary you set in the prompt, which means the boundary has to include how those edits land on disk.

This tutorial gives you a repeatable fix in 13 steps and about 35 minutes. You will reproduce a phantom rewrite on purpose, measure it with plain Git flags, write the attributes file that stops it, align EditorConfig and your formatter, add a short Cursor rule, and ship a small Python audit script that tells real edits from noise. If you want the model-side view of the same problem, our guide on how to tune Cursor Rules for diff versus rewrite edits covers prompts and settings, while this one covers the file system and Git layer underneath.

Everything here runs locally. You need no API key, no paid plan, and no changes to how you pay for Cursor. The sample outputs below come from local runs of the commands.

Google · Preferred Sources

Don't miss new tech stories on Google

Add Tech Insider once in the Google app and our stories appear in your news suggestions.

Add Now

Why Cursor Diffs Sometimes Look Like Full-File Rewrites

A diff is a comparison of bytes, not of meaning. Git sees a carriage return plus a line feed at the end of a line as different from a lone line feed. If an editor, formatter, or agent tool saves a file with the other convention, every line in that file differs from the committed version, even when no human-visible character changed. Your review pane then shows the whole file as removed and re-added.

That looks exactly like what people mean by a “cursor full file rewrite.” The model may well have regenerated the whole file. Research on Cursor’s apply path, as summarized by a single technical source, says localized changes may be applied as search-and-replace style patches while broad structural requests can produce rewrite-equivalent output. No official Cursor page we retrieved confirms a setting that forces every edit into a minimal patch. So you cannot assume the tool gives you small diffs. You have to make the repository tolerant of both outcomes and measure what actually changed.

Three separate things produce a rewrite-looking diff, and they need different fixes:

  • Line-ending flips. CRLF becomes LF or the reverse. Git reports every line as changed. A real edit may hide inside it.
  • Formatter drift. A save-time formatter re-wraps, re-indents, or reorders imports across the whole file after the agent touches one line.
  • A genuine rewrite. The model regenerated most of the file and changed real content. This is a prompt, rule, or review problem, not a Git problem.

The first two are mechanical, and you can eliminate them for good. The third needs human judgment, but you can at least detect it automatically. A reader who wants a deeper look at the third case can follow our walkthrough on how to audit Cursor AI edits for diff versus rewrite. This article stays on the first two and on separating them from the third.

Why does it matter beyond tidiness? A 400-line phantom diff hides the three lines that matter. Reviewers skim, approve, and miss a changed condition. Merge conflicts multiply, because anyone else touching the file now conflicts with every line. And git blame turns useless, since every line in the file now points at the agent’s commit. Fixing the cause costs about half an hour once, and it pays back on every pull request afterward.

Prerequisites and Versions You Need

You need very little. The setup works on macOS, Linux, and Windows with Git for Windows. The table lists what to install and which version we used.

ToolMinimumVersion testedWhy you need it
GitNot listedNot listedProvides --ignore-cr-at-eol, --renormalize, and core.hooksPath
Python3.10 or newer3.12.3Runs the diffnoise audit script
CursorA current 3.x buildNot version-pinnedThe editor whose agent edits you are auditing
EditorConfig supportBuilt in or extensionn/aPins the line ending at the editor level
Prettier (optional)Any current releasen/aOnly needed if your project already formats on save
GitHub Actions (optional)Any plann/aRuns the same audit on pull requests

On the Cursor side, research for this article identified Cursor 3.23, dated October 1, 2026, as the newest IDE release surfaced in Cursor’s release notes. That page was the only source for the number, so treat it as the newest version we could confirm rather than a requirement. Any recent 3.x build handles everything below, because the fix lives in Git and not in the editor.

Check your tools before you start:

git --version
python3 --version
git config --get core.autocrlf || echo "core.autocrlf is unset"
git config --get core.eol || echo "core.eol is unset"

The last two lines matter more than they look. A machine with core.autocrlf=true quietly rewrites line endings on checkout and commit. A teammate with input or false does not. When two machines disagree, Git sees the difference as a rewrite. The goal of this tutorial is to move that decision out of per-machine config and into a file that travels with the repository.

Budget your time like this: steps 1 to 5 take about 10 minutes, steps 6 to 9 take 10 minutes, step 10 takes 3 minutes, step 11 takes 7 minutes, and steps 12 and 13 take 5 minutes. You can stop after step 7 and still remove most phantom diffs, so treat the later steps as hardening.

How Line Endings, Formatters, and Fast Apply Interact

Before you touch anything, it helps to know which layer owns the bytes at each moment. A file goes through five hands on its way from the model to your commit: the model’s output, Cursor’s apply step, the editor’s save logic, any formatter you run on save, and finally Git’s checkin filter. Each can change line endings or whitespace. The last word belongs to Git, and that is why the repository-level fix works where editor-level fixes fail.

Where each layer can introduce noise

The apply step reconciles the model’s proposed edit with your file. Cursor’s own description of fast apply, in the single technical source we found, says it takes the original code plus an edit snippet and produces the merged file. If the merge writes the whole file back, it uses whatever line ending the tool or editor settings choose. If your file was CRLF and the tool wrote LF, you get a phantom rewrite. The model never decided anything about line endings. The platform default did.

Next comes the editor. VS Code and its forks keep a per-file line-ending status in the status bar, and a files.eol setting that sets the default for new files. An EditorConfig file overrides that setting when the extension is active. Finally the formatter runs. Prettier, for example, has its own end-of-line option, and it wins over the editor if the two disagree.

Why a Git attributes file wins

Git’s attributes file works at the one layer every tool shares. When you mark a path as text with a fixed end-of-line, Git converts on checkin and on checkout regardless of what the editor wrote in between. A CRLF file that an agent saved as LF compares as identical to the stored LF blob, so the diff goes quiet. Our fast-apply patch engine tutorial shows what happens one layer up, when you build the merge step yourself. Here you leave the merge alone and normalize its output.

CauseWhat changes in the fileHow to spot itFix in this guide
Line-ending flipCRLF to LF or LF to CRLF on every linegit ls-files --eol shows i/crlf w/lfSteps 6 and 7
Mixed endingsSome lines CRLF, some LFOutput shows i/mixedStep 7 renormalize
Formatter driftWrapping, quotes, import orderDiff survives --ignore-all-spaceStep 9
Trailing whitespaceSpaces at line ends added or strippedVanishes under --ignore-space-at-eolStep 8
Real rewriteMost lines have new contentDiff survives every ignore flagSteps 10 and 11
Final newlineMissing or extra last newline“No newline at end of file” markerStep 8

The “how to spot it” column is the key to the whole tutorial. If a diff vanishes when you tell Git to ignore carriage returns, you had noise. If it survives every ignore flag, the content truly changed. The audit script in step 11 automates that one test.

Steps 1 to 3: Build a Test Repo and Reproduce the Problem

You will not experiment on a real project. Build a throwaway repo, commit a CRLF file, and simulate what a tool does when it rewrites that file with LF endings. This takes five minutes and gives you a baseline to compare against after each fix.

Step 1. Create the repo and pin a neutral config. Setting core.autocrlf to false makes the demo behave the same on every OS:

mkdir phantom-demo && cd phantom-demo
git init -b main
git config user.email "[email protected]"
git config user.name "You"
git config core.autocrlf false
mkdir tools

Step 2. Commit a 60-line CRLF baseline. Three files give you three scenarios. One will get a pure line-ending flip, one a flip plus a single real edit, and one a true rewrite:

python3 - <<'EOF'
lines = [f"export const item{i} = {i};" for i in range(60)]
data = ("\r\n".join(lines) + "\r\n").encode()
for name in ("src_a.ts", "src_b.ts", "src_c.ts"):
    open(name, "wb").write(data)
EOF
git add -A && git commit -m "base"

Step 3. Simulate the agent’s edits. File A gets saved as LF with no content change. File B gets saved as LF with one changed constant. File C gets fully regenerated with new content but keeps CRLF:

python3 - <<'EOF'
l = [f"export const item{i} = {i};" for i in range(60)]
open("src_a.ts", "wb").write(("\n".join(l) + "\n").encode())
l2 = list(l)
l2[10] = "export const item10 = 100;"
open("src_b.ts", "wb").write(("\n".join(l2) + "\n").encode())
l3 = [f"export function fn{i}() {{ return {i}; }}" for i in range(60)]
open("src_c.ts", "wb").write(("\r\n".join(l3) + "\r\n").encode())
EOF
git diff --stat

You should see output like this:

 src_a.ts | 120 +++++++++++++++++++++++++++++++--------------------------------
 src_b.ts | 120 +++++++++++++++++++++++++++++++--------------------------------
 src_c.ts | 120 +++++++++++++++++++++++++++++++--------------------------------
 3 files changed, 180 insertions(+), 180 deletions(-)

All three files show 120 changed lines, which is 60 removed plus 60 added. Plain git diff --stat cannot tell them apart. File A changed nothing a human would notice. File B changed one line. File C changed everything. A reviewer looking at this stat sees three identical rewrites, and that is exactly the confusion you are here to eliminate.

Common pitfall at this stage: if your demo shows no diff at all, your global Git config has core.autocrlf=true or a global attributes file that already normalizes. Run git config --show-origin --get-all core.autocrlf to find where it comes from, and keep the local false override for this demo only.

Steps 4 and 5: Measure Real Edits Against Noise With Git Flags

You now have a repo that shows three identical rewrites. Git itself can already separate them. Two flags do the work: --ignore-cr-at-eol treats a carriage return before a line feed as nothing, and --ignore-all-space (short form -w) ignores whitespace differences inside lines too.

Step 4. Compare the raw stat to the whitespace-blind stat.

git diff --stat
echo "----- ignoring CR at EOL and whitespace -----"
git diff --stat --ignore-cr-at-eol --ignore-all-space
 src_b.ts |   2 +-
 src_c.ts | 120 +++++++++++++++++++++++++++++++--------------------------------
 2 files changed, 61 insertions(+), 61 deletions(-)

That second block is the one to read. File A vanished, which proves it was pure noise. File B collapsed from 120 changed lines to 2, which is one removed and one added line, the single constant you actually changed. File C stayed at 120 because every line has new content. The first number told you nothing. The second number told you what each file really did.

Step 5. Ask Git what it thinks each file’s line endings are. The --eol option of ls-files prints two columns that matter: the line ending in the index (i/) and the one in your working tree (w/).

git ls-files --eol
i/crlf  w/lf    attr/                 	src_a.ts
i/crlf  w/lf    attr/                 	src_b.ts
i/crlf  w/crlf  attr/                 	src_c.ts

Files A and B show i/crlf w/lf. The committed version uses CRLF and the file on disk now uses LF. That mismatch is the signature of a line-ending flip. File C shows w/crlf, so the working tree matches the index and its diff is real content. The empty attr/ column tells you no attribute rule applies to these files yet, which you will fix next.

Make git ls-files --eol a habit. When a Cursor session produces a suspicious diff, this one command tells you in two seconds whether to blame the model or the line endings. It also reveals i/mixed files, which are worse than either pure convention, because they guarantee phantom noise for somebody. If you want to push the measurement further and compare edit mechanisms head to head, the Cursor CLI diff versus rewrite benchmark walks through a timing harness.

Steps 6 and 7: Write the Gitattributes File and Renormalize

This is the core fix. A .gitattributes file at the repo root tells Git how to treat line endings for every path, for every contributor, on every OS. It beats per-machine core.autocrlf because it lives in the repository and so travels with every clone.

Step 6. Create the attributes file.

cat > .gitattributes <<'EOF'
* text=auto eol=lf
*.ps1 text eol=crlf
*.bat text eol=crlf
*.cmd text eol=crlf
*.sln text eol=crlf
*.png binary
*.jpg binary
*.pdf binary
EOF
git ls-files --eol src_a.ts

Read the first line carefully. text=auto lets Git decide per file whether the content is text. eol=lf says that text files check out with LF endings on every platform and store as LF in the index. The next four lines carve out Windows script formats that genuinely need CRLF to run. The last three lines mark binaries so Git never tries to convert them. Corrupted images from a stray conversion are a classic and painful mistake.

i/crlf  w/lf    attr/text=auto eol=lf 	src_a.ts

The attr/ column now shows the rule matching the file. The index still holds CRLF, though, because nothing has re-staged the file under the new rule. That is what step 7 does.

Step 7. Renormalize the existing files in a dedicated commit. First discard the simulated edits so your working tree matches HEAD, then renormalize:

git checkout -- .
git add .gitattributes
git add --renormalize .
git status --short
git commit -m "chore: normalize line endings to LF"
git ls-files --eol src_a.ts
A  .gitattributes
M  src_a.ts
M  src_b.ts
M  src_c.ts
i/lf    w/crlf  attr/text=auto eol=lf 	src_a.ts

Every text file now stores LF in the index. The w/crlf you still see in the working-tree column fixes itself the next time Git touches the file, and Git warns you with “CRLF will be replaced by LF the next time Git touches it.” That warning is expected. It is not an error.

Keep this commit separate from any feature work. A renormalization commit rewrites every text file in your repo, and mixing it with real changes makes the real changes unreviewable. To keep git blame useful, record the commit hash in an ignore file and tell Git to use it:

git rev-parse HEAD >> .git-blame-ignore-revs
git config blame.ignoreRevsFile .git-blame-ignore-revs
git add .git-blame-ignore-revs
git commit -m "chore: ignore normalization commit in blame"

Now repeat the earlier experiment. Write src_a.ts back with CRLF endings and one real change, then run the stat. You will see 2 changed lines, not 120. The attributes file turned a phantom rewrite into a clean one-line diff without any help from Cursor, the model, or the editor.

Steps 8 and 9: Align EditorConfig, Editor Settings, and Your Formatter

The attributes file fixes the diff. It does not stop your editor from writing the wrong endings in the first place, and some tools still look at the working tree. Aligning the editor layers keeps your files clean on disk too, which matters for scripts that read them before Git does.

Step 8. Add an EditorConfig file. EditorConfig is a small cross-editor standard that VS Code forks pick up through an extension or built-in support. Create .editorconfig at the repo root:

root = true

[*]
charset = utf-8
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
indent_style = space
indent_size = 2

[*.{bat,cmd,ps1,sln}]
end_of_line = crlf

[Makefile]
indent_style = tab

[*.md]
trim_trailing_whitespace = false

Three details here earn their place. The CRLF block mirrors the attributes file, so the two never disagree about Windows scripts. The Makefile block protects a file where tabs are syntax. The Markdown exception exists because two trailing spaces mean a hard line break in Markdown, and stripping them silently changes how the document renders.

Then pin the editor’s own default. In Cursor, open the workspace settings JSON and add:

{
  "files.eol": "\n",
  "files.insertFinalNewline": true,
  "files.trimTrailingWhitespace": true
}

Save that as .vscode/settings.json. Cursor is a fork of VS Code, so the same keys apply. Commit the file so teammates inherit the setting. It controls the default for new files, and EditorConfig covers files that already exist.

Step 9. Make the formatter agree. If your project formats on save with Prettier, its own end-of-line option beats EditorConfig. Add a matching setting so the formatter never undoes your work:

{
  "endOfLine": "lf",
  "singleQuote": false,
  "trailingComma": "all"
}

Save it as .prettierrc. The quote and comma options are examples, so keep whatever your project already uses. The only line that matters here is endOfLine. If your team uses a different formatter such as Black, Ruff, or gofmt, check its documentation for the line-ending behavior. Many of them already emit LF, which is one reason some Python and Go repos see fewer phantom rewrites than JavaScript repos with mixed tooling.

Formatter drift needs one more precaution. If you turn on format-on-save and the agent edits a file that was never formatted, the save re-wraps the whole file. That is a legitimate rewrite-looking diff, and no attribute rule can hide it. Run the formatter across the repo once in its own commit, add that hash to .git-blame-ignore-revs too, and from then on the formatter only touches lines the agent actually changed.

Step 10: Give Cursor a Short Rule About Edit Hygiene

The Git layer now absorbs line-ending noise. The model-side question remains: how do you ask the agent to leave untouched lines alone? Cursor’s project rules are the supported place for persistent instructions. Third-party documentation says they live in .cursor/rules as .mdc files, and that Cursor also supports an AGENTS.md file for the same purpose.

Be honest about what a rule can do. Research for this article found no official Cursor source that says a natural-language rule can technically prevent a full-file rewrite. A rule asks the model to behave. It does not enforce anything. That is why this guide puts the enforcement in Git and treats the rule as a hint, backed up by the audit script in the next step.

Create .cursor/rules/edit-hygiene.mdc:

---
description: Keep edits small and byte-clean
globs:
alwaysApply: true
---

- Make the smallest change that satisfies the request. Do not refactor.
- Never reformat, re-wrap, or reorder lines you were not asked to change.
- Preserve the existing line endings. This repo stores LF (see .gitattributes).
- Do not rewrite a whole file to change a few lines. Edit in place.
- If a change needs more than about 30 changed lines in one file, stop and
  say so before editing.
- After editing, name every file you touched and why.

Keep rules short. A long rule file competes with the actual task for the model’s attention, and the instructions that matter get diluted. The “stop and say so” line gives you an early warning for large edits. Our guide on catching bad rewrites with a Fast Apply safety net goes deeper on reviewing what the agent proposes, and the Cursor Composer diff versus rewrite setup covers the Composer-specific controls.

Which model sits behind the agent also affects behavior, and the lineup moves quickly. Cursor’s release notes show Grok 4.7 added on September 21, 2026, Claude Sonnet 5.5 on September 28, and Claude Haiku 5.5 as an opt-in model on October 5, with Claude Opus 5.5 on the list as well. We did not benchmark rewrite habits across these models for this guide. The point of the Git-level fix is that you no longer need to.

Step 11: Build the diffnoise Audit Script

Attributes and rules reduce noise. They do not prove anything. You want a check that looks at a real diff and says one of four things for each file: fine, whitespace-only, mostly noise, or a true rewrite. That check is about 90 lines of Python and relies on nothing beyond Git.

The logic runs in three stages. For each changed file it gathers the raw count of changed lines, then the count when whitespace and carriage returns are ignored, then the number of lines in the base version. The difference between raw and real is noise. The ratio of real changes to base size reveals a true rewrite. A Cursor Rules VS Code extension could surface the same verdicts inside the editor, but a plain script runs in hooks and CI with no editor at all.

Save this as tools/diffnoise.py:

#!/usr/bin/env python3
"""Classify git diffs as real edits, whitespace/EOL noise, or true rewrites."""
import argparse
import json
import subprocess
import sys


def git(*args):
    return subprocess.run(["git", *args], capture_output=True, text=True, check=True).stdout


def numstat(diff_args, extra=()):
    out = git("diff", "--numstat", "--no-renames", *extra, *diff_args)
    stats = {}
    for line in out.splitlines():
        added, deleted, path = line.split("\t", 2)
        if added == "-":
            continue
        stats[path] = int(added) + int(deleted)
    return stats


def base_lines(base, path):
    proc = subprocess.run(["git", "show", f"{base}:{path}"], capture_output=True)
    if proc.returncode != 0 or not proc.stdout:
        return 0
    data = proc.stdout
    return data.count(b"\n") + (0 if data.endswith(b"\n") else 1)


def classify(raw, clean, size, rewrite_ratio, noise_share):
    if raw > 0 and clean == 0:
        return "PHANTOM"
    if raw > 0 and (raw - clean) / raw >= noise_share:
        return "NOISY"
    if size > 0 and clean / size >= rewrite_ratio:
        return "REWRITE"
    return "OK"


def main():
    p = argparse.ArgumentParser()
    mode = p.add_mutually_exclusive_group()
    mode.add_argument("--staged", action="store_true", help="audit the index")
    mode.add_argument("--range", help="audit a range such as origin/main...HEAD")
    p.add_argument("--rewrite-ratio", type=float, default=0.6)
    p.add_argument("--noise-share", type=float, default=0.5)
    p.add_argument("--min-lines", type=int, default=20)
    p.add_argument("--json", action="store_true")
    p.add_argument("--fail", action="store_true", help="exit 1 on any non-OK file")
    a = p.parse_args()

    if a.staged:
        diff_args, base = ["--cached"], "HEAD"
    elif a.range:
        diff_args = [a.range]
        left = a.range.split("...")[0].split("..")[0]
        right = a.range.split("...")[-1].split("..")[-1]
        base = git("merge-base", left, right).strip() if "..." in a.range else left
    else:
        diff_args, base = [], "HEAD"

    raw = numstat(diff_args)
    clean = numstat(diff_args, ["--ignore-cr-at-eol", "--ignore-all-space"])
    rows = []
    for path, r in sorted(raw.items()):
        c = clean.get(path, 0)
        size = base_lines(base, path)
        verdict = "OK"
        if r >= a.min_lines or c == 0:
            verdict = classify(r, c, size, a.rewrite_ratio, a.noise_share)
        rows.append({"file": path, "raw": r, "real": c, "noise": r - c,
                     "base_lines": size, "verdict": verdict})

    if a.json:
        print(json.dumps(rows, indent=2))
    else:
        print(f"{'FILE':32} {'RAW':>5} {'REAL':>5} {'NOISE':>5} {'BASE':>5}  VERDICT")
        for x in rows:
            print(f"{x['file']:32} {x['raw']:>5} {x['real']:>5} "
                  f"{x['noise']:>5} {x['base_lines']:>5}  {x['verdict']}")
        if not rows:
            print("no changes")
    bad = [x for x in rows if x["verdict"] != "OK"]
    sys.exit(1 if (a.fail and bad) else 0)


if __name__ == "__main__":
    main()

A few design choices deserve a word. The script passes --no-renames so a moved file counts as a delete plus an add and never hides a rewrite inside a rename. It skips binary files, which Git reports with dashes in numstat. And it applies the --min-lines floor so a three-line change to a four-line file does not trip the rewrite check, while a file whose real count is zero still gets flagged as phantom no matter how small.

VerdictRuleWhat it meansWhat to do
PHANTOMRaw changes above zero, real changes zeroOnly whitespace or line endings movedDiscard the change or renormalize
NOISYHalf or more of the changed lines are noiseA real edit hides inside a flipFix the endings, then review the real lines
REWRITEReal changes reach most of the base fileContent changed almost everywhereReview line by line or reject the edit
OKNone of the aboveA normal targeted changeReview as usual

Run it against the demo repo from steps 1 to 3 (before the renormalization commit). The output for the three simulated files looks like this:

FILE                               RAW  REAL NOISE  BASE  VERDICT
src_a.ts                           120     0   120    60  PHANTOM
src_b.ts                           120     2   118    60  NOISY
src_c.ts                           120   120     0    60  REWRITE

Those three verdicts match what you knew when you built the files, which is the whole test. File A is pure noise. File B hides one real edit inside a pile of noise. File C is a genuine rewrite. Plain git diff --stat called all three the same thing. Beyond that, the exit code gives you something to automate: run it with --fail and any non-OK file returns exit code 1.

Steps 12 and 13: Wire a Pre-Commit Hook and a CI Check

A script you have to remember to run protects nobody. Put it where it runs by itself: a local hook that stops a bad commit before it exists, and a CI job that catches whatever slips past a skipped hook.

Step 12. Add the pre-commit hook. Keep the hook inside the repository so every clone gets it, and point Git at that directory:

mkdir -p .githooks
printf '#!/bin/sh\npython3 tools/diffnoise.py --staged --fail\n' > .githooks/pre-commit
chmod +x .githooks/pre-commit
git config core.hooksPath .githooks
git add .githooks tools/diffnoise.py
git commit -m "chore: add diffnoise pre-commit audit"

Each teammate has to run the git config core.hooksPath line once, because Git does not copy config across clones. Put that line in your README or a setup script. When the hook finds a PHANTOM, NOISY, or REWRITE file, it prints the table above and aborts the commit. You then decide: discard the file, fix its endings, or confirm the rewrite is intentional and commit with git commit --no-verify as a deliberate, visible override.

Be careful with that override. Skipping hooks should be an exception that you can explain in the commit message, not a reflex. If the hook blocks you more than once a week, the thresholds are wrong for your repo, not the hook. The advanced section below shows how to tune them.

Step 13. Add the CI job. A hook can be skipped or never installed. A CI check cannot. Create .github/workflows/diffnoise.yml:

name: diffnoise
on:
  pull_request:

jobs:
  audit:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0
      - name: Audit pull request diff
        run: python3 tools/diffnoise.py --range origin/${{ github.base_ref }}...HEAD --fail

The fetch-depth: 0 line matters. A shallow checkout has no merge base, and the script’s range mode fails with a “bad revision” error. The three-dot range compares your branch against the point where it split from the base branch, which matches what reviewers see on the pull request page. If you use another CI system, the command is the same. Only the checkout depth setting changes.

With both in place, a phantom rewrite can reach your main branch only if someone bypasses the hook and the CI check, which means doing it on purpose and in the open. You have moved the guarantee out of anyone’s memory and into the pipeline. Finish by running the whole thing once on a fresh branch with a deliberate CRLF edit, so you see the hook fire with your own eyes.

The Complete Working Project and Its Output

Here is what the finished repo looks like. Every file below came from a step above, and the whole set is small enough to copy into an existing project in a single pass.

phantom-demo/
├── .cursor/
│   └── rules/
│       └── edit-hygiene.mdc
├── .githooks/
│   └── pre-commit
├── .github/
│   └── workflows/
│       └── diffnoise.yml
├── .vscode/
│   └── settings.json
├── tools/
│   └── diffnoise.py
├── .editorconfig
├── .git-blame-ignore-revs
├── .gitattributes
├── .prettierrc
├── src_a.ts
├── src_b.ts
└── src_c.ts

To prove the project works end to end, simulate the original failure after everything is installed. Rewrite src_a.ts with CRLF endings and one real change on line 6, then check Git’s view and the audit:

python3 - <<'EOF'
l = [f"export const item{i} = {i};" for i in range(60)]
l[5] = "export const item5 = 55;"
open("src_a.ts", "wb").write(("\r\n".join(l) + "\r\n").encode())
EOF
git diff --stat
python3 tools/diffnoise.py --min-lines 1
git add src_a.ts && git commit -m "change item5"
warning: in the working copy of 'src_a.ts', CRLF will be replaced by LF the next time Git touches it
 src_a.ts | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)
FILE                               RAW  REAL NOISE  BASE  VERDICT
src_a.ts                             2     2     0    60  OK

Compare that to step 3. The same CRLF write that used to produce a 120-line diff now produces a two-line diff, one removed line and one added line. The audit reports OK, the commit passes the hook, and a reviewer sees exactly the one constant that changed. No model, rule, or editor setting did that work. The repository’s own attributes did.

For more tutorials in the same area, browse the software coverage hub. When you adapt the project to a real codebase, copy the files in this order: the attributes file, a dedicated renormalization commit, EditorConfig, the formatter config, the rule, the script, the hook, and the CI job. That order keeps each commit reviewable and puts the biggest fix first.

Common Pitfalls That Bring Phantom Diffs Back

These six mistakes account for most of the “it still shows the whole file” complaints from teams that did the setup halfway.

  • Mixing the renormalization with feature work. One commit that normalizes 300 files and changes logic is unreviewable and unblameable. Always normalize alone, record the hash, and keep it out of blame.
  • Relying on core.autocrlf alone. It lives in each developer’s local config, and cloud agents or CI containers never see it. The attributes file travels with the repo. Use the attributes file as the source of truth and treat autocrlf as a fallback.
  • Forgetting binary files. text=auto guesses well but not perfectly. A mis-detected PNG or font corrupts on conversion. List the binary extensions you use as binary explicitly.
  • Forcing LF on Windows-only scripts. A batch file with LF endings can fail in strange ways. Keep the CRLF carve-outs for .bat, .cmd, .ps1, and Visual Studio solution files.
  • Trusting --ignore-all-space in indentation-sensitive code. In Python, YAML, and Makefiles, a whitespace change is a real change. The script’s “real” count can undercount in those files. Drop the --ignore-all-space flag and keep only --ignore-cr-at-eol for repos where indentation carries meaning.
  • Running git add --renormalize . on a dirty tree. You mix your uncommitted work into the normalization commit. Commit or stash first, so the commit contains only line-ending changes.

The fifth pitfall deserves a second look because it fails silently. The audit script reports a file as OK when the only change is a re-indented block of Python, and Python reads indentation as structure. If your repo is mostly Python or YAML, open the script and change the second numstat call to pass only --ignore-cr-at-eol. You lose some noise detection in exchange for never hiding a structural change.

Another subtle one: cloud and remote agents. Cursor announced support for running cloud agents on customer-managed machines on September 2, 2026, according to The AI Agent Index review of Cursor. An agent running on a Linux box and a developer on Windows are exactly the pair that produces line-ending mismatches. The attributes file makes their output byte-identical in the index no matter where each edit ran.

Troubleshooting: 10 Problems and Their Fixes

Each item below lists the symptom first, then the cause, then the fix.

  1. The diff still shows every line after you added the attributes file. The index still holds old endings. Run git add --renormalize ., check git ls-files --eol for i/lf, and commit.
  2. Git prints “CRLF will be replaced by LF the next time Git touches it.” This is a notice, not a failure. It confirms the attribute rule is active. Nothing needs fixing.
  3. “fatal: CRLF would be replaced by LF” blocks a command. You have core.safecrlf=true and the file has mixed endings. Fix the file first (step 7), or set core.safecrlf to warn while you clean up.
  4. The pre-commit hook never runs. Check git config core.hooksPath prints .githooks and that the file has the execute bit. On Windows, make sure the first line is #!/bin/sh and Git for Windows can find a shell.
  5. The script crashes with “fatal: bad revision” in CI. The checkout is shallow or the base branch was not fetched. Set fetch-depth: 0 and use the pull request’s base ref as shown in step 13.
  6. git ls-files --eol shows i/mixed. One file contains both endings. Renormalize it, or convert it by hand with sed -i 's/\r$//' file and re-stage. Then investigate which tool wrote the stray endings.
  7. A .bat or .ps1 file breaks after normalization. The CRLF carve-out is missing or the pattern does not match. Add *.bat text eol=crlf and renormalize again.
  8. The editor shows red and green but git diff is empty. The editor typically compares against its own pre-edit snapshot, while Git compares against HEAD after its attribute filter runs. Trust Git for what will be committed, and keep the editor’s accept and reject buttons for the review step.
  9. Prettier keeps flipping endings after you set EditorConfig. Prettier’s own endOfLine option takes priority. Set it to lf in .prettierrc so all layers agree.
  10. Lines starting with -- (SQL comments, for example) render wrongly in an agent diff. Cursor’s release notes dated October 8, 2026 reportedly describe a fix for removed lines beginning with -- in agent diff rendering. Update Cursor, and rely on git diff in the meantime.

One more case worth knowing: a very large change. Cursor’s 3.23 release notes, dated October 1, 2026, reportedly added support for reviewing large diffs by listing changed files and line counts, and explaining when content exceeds the viewing limit. When the editor stops showing a file inline, fall back to git diff --stat and run the audit script. It works on any size of change.

Advanced Tips for Large Repos and Teams

Once the basics hold, a few additions make the setup fit a monorepo or a team of mixed platforms. None are required, so add them as you hit the problem each one solves.

Tune thresholds and output per repo

The defaults suit a typical application repo. A repo full of small config files wants a lower --min-lines, and a repo with large generated files wants a higher one or an exclusion list. For dashboards, use --json and pipe it into jq:

python3 tools/diffnoise.py --range origin/main...HEAD --json \
  | jq -r '.[] | select(.verdict != "OK") | "\(.verdict)\t\(.file)\t\(.raw)"'

Lowering --rewrite-ratio makes the check stricter about large content changes. Raising it makes it flag only near-total regeneration. Start at the default and adjust after a week of real pull requests, not before.

Make Git itself produce cleaner diffs

Two settings improve how Git draws any diff, including those from agent sessions. Switch the algorithm to histogram or patience, which handle moved blocks better than the default. Then mark generated and lock files so GitHub collapses them in review:

git config diff.algorithm histogram
printf 'package-lock.json linguist-generated=true\n*.min.js -diff linguist-generated=true\n' >> .gitattributes

The linguist-generated attribute tells GitHub to fold those files in the pull request view, and -diff stops Git from printing minified content in the terminal. Together they remove a big source of false “huge rewrite” alarms in JavaScript projects. If you build your own editor tooling around these checks, our tutorial on a VS Code AI extension that also works in Cursor shows how to surface verdicts in the status bar.

A final habit pays off in every setup: commit before you start an agent session. A clean tree means git diff shows only what the agent did, and git restore . undoes all of it. Cursor’s own pace of change argues for this too. Its October 16 update, listed by Agent Breaking Changes as Cloud Agent Canvases and Environment Improvements, is one of several releases that month, and each one can shift how edits are produced. A byte-level safety net outlasts all of them.

Frequently Asked Questions

Does Cursor really rewrite the whole file when I ask for a small change? Sometimes, but not as often as the diff suggests. A single technical source describes Cursor’s apply step as treating localized edits like patches and broad structural requests as closer to rewrites. No official page we found guarantees minimal diffs. Check git ls-files --eol and the audit script before blaming the model.

Is a gitattributes file better than setting core.autocrlf? For a team, yes. The attributes file lives in the repository, so every clone, CI runner, and cloud agent applies the same rule. The core.autocrlf setting is local to one machine and one user, so it can disagree with a teammate’s.

Will renormalizing break my Windows scripts? Not if you keep the carve-outs. Mark .bat, .cmd, .ps1, and .sln files with eol=crlf as in step 6, and they keep the endings Windows expects while everything else stores LF.

Can a Cursor rule stop full-file rewrites? A rule can ask the agent to make small edits, and in practice that helps. Research for this guide found no official source that says a rule enforces it. Use the rule as guidance and the hook and CI job as enforcement.

Why does the audit script ignore whitespace? It needs a way to tell noise from content, and whitespace-blind comparison is the cheapest reliable test. The tradeoff is that indentation-sensitive languages can hide real changes, so drop the --ignore-all-space flag for Python and YAML repos.

How long does the full setup take? About 35 minutes on a fresh demo repo. On a real project, the longest part is the one-time renormalization commit and agreeing on it with your team. Steps 1 to 7 alone remove most phantom diffs in about 20 minutes.

Does this work with other AI editors? Yes. The fix sits at the Git layer, so any tool that writes files to your working tree benefits, including other agents and editors. Only the rule file in step 10 is specific to Cursor.

Sofia LindstrÃķm

Sofia LindstrÃķm

Editor-in-Chief

Sofia LindstrÃķm is the Editor-in-Chief at Tech Insider, where she leads editorial strategy and oversees coverage across AI, cybersecurity, and enterprise technology. With over a decade in Swedish tech journalism, she previously served as technology editor at Dagens Industri and covered the Nordic startup ecosystem for Breakit. Sofia holds an MSc in Media Technology from KTH Royal Institute of Technology and is a frequent speaker at Web Summit and Slush. She is passionate about making complex technology accessible to business leaders.

View all articles