Short answer: paste the script below into clean_folder.py, run it on your folder without any flags, and it tells you every file that contains invisible watermark characters and how many. Add --write and it rewrites them in place, keeping your original line endings. Then install the git hook so the same characters cannot get back into the repository.
python clean_folder.py ./drafts # report only python clean_folder.py ./drafts --write # rewrite in place python clean_folder.py . --check # exit 1 if any found (for CI)
The characters being removed are the Unicode format and control characters that travel with copied AI output — U+200B through U+200F, U+2060 through U+206F, U+FEFF, U+00AD, U+034F, U+180E, U+061C, U+2028, U+2029 and U+FFF9 through U+FFFB. They are invisible in every editor, which is exactly why a folder-wide check beats reviewing files one at a time.
#!/usr/bin/env python3
"""Strip invisible watermark characters from a folder. Dry run unless --write."""
import argparse, pathlib, re, sys
PATTERN = re.compile(
"[\u200B-\u200F\u202A-\u202E\u2060-\u206F"
"\uFEFF\u00AD\u034F\u180E\u061C\u2028\u2029\uFFF9-\uFFFB]"
)
DEFAULT_EXT = [".txt", ".md", ".csv", ".tsv", ".json", ".html", ".htm",
".xml", ".svg", ".js", ".ts", ".css", ".yml", ".yaml", ".srt"]
def main():
ap = argparse.ArgumentParser()
ap.add_argument("root")
ap.add_argument("--write", action="store_true", help="rewrite files in place")
ap.add_argument("--check", action="store_true", help="exit 1 if anything is found")
ap.add_argument("--ext", nargs="*", default=DEFAULT_EXT)
args = ap.parse_args()
files = chars = 0
for path in sorted(pathlib.Path(args.root).rglob("*")):
if not path.is_file() or path.suffix.lower() not in args.ext:
continue
try:
text = path.read_bytes().decode("utf-8")
except UnicodeDecodeError:
continue # binary or non-UTF-8: skip
hits = PATTERN.findall(text)
if not hits:
continue
files += 1
chars += len(hits)
print(f"{path}: {len(hits)}")
if args.write:
with open(path, "w", encoding="utf-8", newline="") as fh:
fh.write(PATTERN.sub("", text))
verb = "rewrote" if args.write else "found in"
print(f"\n{chars} characters {verb} {files} file(s)")
if args.check and chars:
sys.exit(1)
if __name__ == "__main__":
main()
Three details in there matter more than they look:
newline="" when writing. Without it Python rewrites every CRLF as LF on Windows and your next git diff shows the whole file as changed. This keeps the original line endings, so the diff shows only the characters that were actually removed.--check exits non-zero. That single flag turns the same script into a CI gate instead of a report nobody reads.Run it on copies first. The script decodes as UTF-8 and skips anything it cannot decode, but a file that is mostly text with a few binary bytes in it will still be rewritten.
Cleaning a folder once is worth nothing if the next paste puts the characters straight back. Put the check in front of the commit instead. Save this as .githooks/pre-commit in your repository:
#!/bin/sh
# Reject commits that contain invisible watermark characters.
files=$(git diff --cached --name-only --diff-filter=ACM)
[ -z "$files" ] && exit 0
python - "$files" <<'PY'
import re, subprocess, sys
PATTERN = re.compile("[\u200B-\u200F\u202A-\u202E\u2060-\u206F"
"\uFEFF\u00AD\u034F\u180E\u061C\u2028\u2029\uFFF9-\uFFFB]")
bad = 0
for name in sys.argv[1:]:
blob = subprocess.run(["git", "show", f":{name}"], capture_output=True).stdout
try:
text = blob.decode("utf-8")
except UnicodeDecodeError:
continue
for i, ch in enumerate(text):
if PATTERN.match(ch):
print(f"{name}: U+{ord(ch):04X} at character {i}")
bad += 1
if bad:
print(f"\n{bad} invisible character(s) staged. "
"Run: python clean_folder.py . --write then re-add the files.")
sys.exit(1)
PY
Then point git at the folder and make it executable:
git config core.hooksPath .githooks chmod +x .githooks/pre-commit
Using core.hooksPath rather than dropping the file in .git/hooks means the hook is versioned with the project, so everyone on the team gets it on their next pull instead of you explaining it in chat. On Windows the chmod is optional — Git for Windows runs the hook either way.
The hook checks the staged version of each file (git show :file), not what is on disk, which is what you want: it judges exactly what the commit would contain. It prints the character index, so when it blocks you, you know whether you are looking at one stray U+200B or a file that came in wholesale from a chat window.
Save as .github/workflows/invisible-characters.yml. The hook only runs on machines where someone configured it; CI is the backstop that runs whether or not anyone did.
name: invisible-characters
on: [push, pull_request]
jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: python clean_folder.py . --check
Because --check exits 1 when it finds anything, the job fails and the pull request shows a red mark with the offending file list in the log. No extra tooling, no third-party action, one file.
Blanket removal is right for prose and wrong for some other files. The four cases that actually bite:
| Case | What happens | What to do |
|---|---|---|
| Arabic, Persian, Hindi, and emoji sequences | U+200C and U+200D are load-bearing there — removing them changes how the text renders and, in some words, changes the word | If you write in those scripts, drop \u200C\u200D from PATTERN or exclude those directories with --ext |
| JavaScript and TypeScript | U+2028 and U+2029 are legal inside string literals since ES2019, but they still break line-based tooling and older parsers | Run the script, then read the diff before committing — do not let the hook auto-fix code |
| CSV and JSON data | Removing U+2028 / U+2029 is usually the fix, not the bug — they are a common cause of "unexpected end of file" | Clean, then re-run your parser to confirm the row count is unchanged |
| .docx, .xlsx, .pptx | These are ZIP archives. The text lives in XML parts inside, so a text-file pass will skip them silently | Unzip, clean word/document.xml (or the equivalent part), rezip — or handle those files one at a time in the browser tool |
One honest limitation: the script handles text files. Image metadata (EXIF, XMP, C2PA) is a different job — it lives in binary segments, not in characters, so a regex cannot touch it. The Claude Watermark Remover on this site strips those segments from JPEG and PNG one file at a time, entirely in your browser, with no upload.
Does this remove the ChatGPT watermark?
It removes the invisible characters that travel with copied output. If you mean a statistical watermark — a pattern in how the model chooses words — that lives in the model's output distribution, not in your file, and no script can remove it from text that is already written. Those two things get conflated constantly.
Will it change how my document looks?
No. Every character removed has zero width or is a control character. Compare the rendered page before and after if you want proof, but the visible text is identical.
Can I run it on a folder of client work?
That is what the dry run is for: run without --write first, read the list, then decide. Keep a backup until you have confirmed the output — the script rewrites in place and there is no undo.
Why not just paste everything into an online remover?
Because that means uploading the folder, one file at a time, to a server you do not control. The character list is public and printed above; there is nothing a remote service can do that this script cannot do locally.
How do I check a single file quickly?python clean_folder.py ./one-file.md — the argument is a path, and a file path works as well as a directory.