From 7ddda577842db9c00007d3760cf1525f82c28ed9 Mon Sep 17 00:00:00 2001 From: smngvlkz Date: Sat, 10 Oct 2026 21:37:47 +0100 Subject: [PATCH] fix(hooks): pick up changed files with non-ASCII names (#4278) Landed from PR #4279 (squashed, authorship preserved). --- graphify/hooks.py | 45 ++++++++++++++++++++++++++++++-- tests/test_hooks.py | 63 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 106 insertions(+), 2 deletions(-) diff --git a/graphify/hooks.py b/graphify/hooks.py index abc869c19..9b52dd8d4 100644 --- a/graphify/hooks.py +++ b/graphify/hooks.py @@ -137,6 +137,42 @@ if [ -z "$GRAPHIFY_PYTHON" ]; then fi """ +_GIT_C_ESCAPES = { + "a": 7, "b": 8, "t": 9, "n": 10, "v": 11, "f": 12, "r": 13, '"': 34, "\\": 92, +} + + +def _unquote_git_path(name: str) -> str: + """Decode a path as printed by ``git diff --name-only``. + + Even with ``core.quotePath=false`` git wraps a name containing a double + quote, backslash or control character in double quotes and C-escapes it, + spelling raw bytes as ``\\ooo`` octal. Unquoted names are returned as-is. + """ + if len(name) < 2 or not (name.startswith('"') and name.endswith('"')): + return name + body = name[1:-1] + out = bytearray() + i = 0 + while i < len(body): + ch = body[i] + if ch != "\\" or i + 1 >= len(body): + out += ch.encode("utf-8") + i += 1 + continue + nxt = body[i + 1] + if nxt in "0123" and i + 3 < len(body) and all(c in "01234567" for c in body[i + 1:i + 4]): + out.append(int(body[i + 1:i + 4], 8)) + i += 4 + elif nxt in _GIT_C_ESCAPES: + out.append(_GIT_C_ESCAPES[nxt]) + i += 2 + else: + out += ch.encode("utf-8") + i += 1 + return os.fsdecode(bytes(out)) + + # The Python that the rebuild runs, shared by both hooks. Embedded verbatim into # the launcher below and re-executed in the detached child. Must not contain the # double-quote, $, backtick or backslash characters: it is carried inside a @@ -145,8 +181,10 @@ _REBUILD_BODY_COMMIT = """\ import os, signal, sys, threading, multiprocessing from pathlib import Path +from graphify.hooks import _unquote_git_path + changed_raw = os.environ.get('GRAPHIFY_CHANGED', '') -changed = [Path(f.strip()) for f in changed_raw.strip().splitlines() if f.strip()] +changed = [Path(_unquote_git_path(f.strip())) for f in changed_raw.strip().splitlines() if f.strip()] if not changed: sys.exit(0) @@ -410,7 +448,10 @@ GIT_DIR=${GIT_DIR:-$(git rev-parse --git-dir 2>/dev/null)} [ "${GRAPHIFY_SKIP_HOOK:-0}" = "1" ] && exit 0 """ + _WORKTREE_GUARD + """ -CHANGED=$(git diff --name-only HEAD~1 HEAD 2>/dev/null || git diff --name-only HEAD 2>/dev/null) +# core.quotePath=false: by default git prints a non-ASCII path as a quoted +# octal-escaped string, which matches no file on disk, so the edit was silently +# left out of the rebuild. Names git still quotes are decoded in the rebuild. +CHANGED=$(git -c core.quotePath=false diff --name-only HEAD~1 HEAD 2>/dev/null || git -c core.quotePath=false diff --name-only HEAD 2>/dev/null) if [ -z "$CHANGED" ]; then exit 0 fi diff --git a/tests/test_hooks.py b/tests/test_hooks.py index 8743614ab..6c208c70b 100644 --- a/tests/test_hooks.py +++ b/tests/test_hooks.py @@ -1655,3 +1655,66 @@ def test_status_compares_only_the_graphify_block(tmp_path): hook.write_text(hook.read_text(encoding="utf-8") + "echo 'user after'\n", encoding="utf-8") assert "out of date" not in status(repo) + + +# ── git quotes non-ASCII paths in `git diff --name-only` ──────────────────── + +@pytest.mark.parametrize("raw,expected", [ + ("pkg/plain.py", "pkg/plain.py"), + ("ünïcödé/naïve.py", "ünïcödé/naïve.py"), + ('"\\303\\274n\\303\\257c\\303\\266d\\303\\251/na\\303\\257ve.py"', "ünïcödé/naïve.py"), + ('"\\344\\270\\255\\346\\226\\207/a.py"', "中文/a.py"), + ('"say \\"hi\\".py"', 'say "hi".py'), + ('"tab\\there.py"', "tab\there.py"), + ('"back\\\\slash.py"', "back\\slash.py"), +]) +def test_unquote_git_path(raw, expected): + """git C-quotes some paths in --name-only output; the hook must decode them + back to the real file name or the change never reaches the rebuild.""" + from graphify.hooks import _unquote_git_path + assert _unquote_git_path(raw) == expected + + +def test_post_commit_changed_list_matches_real_non_ascii_files(tmp_path): + """End-to-end against real git: every path the post-commit hook collects + for a commit touching non-ASCII and quote-bearing names must exist on disk + after decoding, so the incremental rebuild actually re-extracts them.""" + if shutil.which("git") is None: # pragma: no cover + pytest.skip("git not available") + if shutil.which("sh") is None: # pragma: no cover + pytest.skip("sh not available") + from graphify.hooks import _HOOK_SCRIPT, _unquote_git_path + + names = ["plain.py", "ünïcödé/naïve.py", "中文/用户.py"] + if os.name != "nt": + names.append('say "hi".py') + + def _git(*args): + subprocess.run(["git", *args], cwd=tmp_path, check=True, capture_output=True) + + _git("init", "-q", ".") + _git("config", "user.email", "t@t.co") + _git("config", "user.name", "t") + _git("config", "core.quotePath", "true") # git's default; pin it against user config + (tmp_path / "seed.txt").write_text("x") + _git("add", "-A") + _git("commit", "-qm", "init") + for name in names: + p = tmp_path / name + p.parent.mkdir(parents=True, exist_ok=True) + p.write_text("def f():\n return 1\n", encoding="utf-8") + _git("add", "-A") + _git("commit", "-qm", "edit") + + changed_line = next(l for l in _HOOK_SCRIPT.splitlines() if l.startswith("CHANGED=$(")) + r = subprocess.run(["sh", "-c", changed_line + '\nprintf "%s\\n" "$CHANGED"'], + cwd=tmp_path, capture_output=True, text=True, encoding="utf-8") + raw = [l.strip() for l in r.stdout.splitlines() if l.strip()] + # Non-ASCII names come through verbatim (core.quotePath=false)... + for name in names: + if '"' not in name: + assert name in raw + # ...and names git still quotes decode back to the real file. + got = [_unquote_git_path(l) for l in raw] + assert sorted(got) == sorted(names) + assert all((tmp_path / g).is_file() for g in got)