fix(hooks): pick up changed files with non-ASCII names (#4278)

Landed from PR #4279 (squashed, authorship preserved).
This commit is contained in:
smngvlkz authored and safishamsi committed 2026-10-10 21:37:47 +01:00
1 parent 1088605003
commit 7ddda57784
2 files changed
+106 -2

No files matched your search

+43 -2
View File
@@ -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
+63
View File
@@ -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)