Skip to content

Commit 5ea06be

Browse files
committed
Fix status_file (and index/tree path handling) for non-ASCII paths
Encode repository-internal paths as UTF-8 instead of filesystem encoding, and normalize to NFC on macOS to match Git's core.precomposeunicode default. status_file now also accepts raw bytes. Add regression tests covering non-ASCII, non-breaking space, NFC/NFD, and raw bytes paths. Fixes #687 Assisted-by: Kimi Code
1 parent d532da7 commit 5ea06be

8 files changed

Lines changed: 156 additions & 17 deletions

File tree

pygit2/_pygit2.pyi

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -760,7 +760,7 @@ class Repository:
760760
def status(
761761
self, untracked_files: str = 'all', ignored: bool = False
762762
) -> dict[str, int]: ...
763-
def status_file(self, path: str, /) -> int: ...
763+
def status_file(self, path: str | bytes, /) -> int: ...
764764
def walk(
765765
self, oid: _OidArg | None, sort_mode: SortMode = SortMode.NONE
766766
) -> Walker: ...
@@ -850,9 +850,9 @@ class Tree(Object):
850850
@disjoint_base
851851
class TreeBuilder:
852852
def clear(self) -> None: ...
853-
def get(self, name: str, /) -> Object: ...
853+
def get(self, name: str | bytes, /) -> Object: ...
854854
def insert(self, name: str, oid: _OidArg, attr: int) -> None: ...
855-
def remove(self, name: str, /) -> None: ...
855+
def remove(self, name: str | bytes, /) -> None: ...
856856
def write(self) -> Oid: ...
857857
def __len__(self) -> int: ...
858858

pygit2/index.py

Lines changed: 16 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,13 @@
3333
from .enums import DiffOption, FileMode
3434
from .errors import check_error
3535
from .ffi import C, ffi
36-
from .utils import GenericIterator, StrArray, decode_fs_path, encode_fs_path
36+
from .utils import (
37+
GenericIterator,
38+
StrArray,
39+
decode_fs_path,
40+
encode_fs_path,
41+
encode_git_path,
42+
)
3743

3844
if typing.TYPE_CHECKING:
3945
from .repository import Repository
@@ -79,7 +85,7 @@ def __len__(self) -> int:
7985
return C.git_index_entrycount(self._index)
8086

8187
def __contains__(self, path) -> bool:
82-
err = C.git_index_find(ffi.NULL, self._index, encode_fs_path(path))
88+
err = C.git_index_find(ffi.NULL, self._index, encode_git_path(path))
8389
if err == C.GIT_ENOTFOUND:
8490
return False
8591

@@ -89,7 +95,7 @@ def __contains__(self, path) -> bool:
8995
def __getitem__(self, key: str | int | PathLike[str]) -> 'IndexEntry':
9096
centry = ffi.NULL
9197
if isinstance(key, str) or hasattr(key, '__fspath__'):
92-
centry = C.git_index_get_bypath(self._index, encode_fs_path(key), 0)
98+
centry = C.git_index_get_bypath(self._index, encode_git_path(key), 0)
9399
elif isinstance(key, int):
94100
if key >= 0:
95101
centry = C.git_index_get_byindex(self._index, key)
@@ -180,12 +186,12 @@ def write_tree(self, repo: 'Repository | None' = None) -> Oid:
180186

181187
def remove(self, path: PathLike[str] | str, level: int = 0) -> None:
182188
"""Remove an entry from the Index."""
183-
err = C.git_index_remove(self._index, encode_fs_path(path), level)
189+
err = C.git_index_remove(self._index, encode_git_path(path), level)
184190
check_error(err, io=True)
185191

186192
def remove_directory(self, path: PathLike[str] | str, level: int = 0) -> None:
187193
"""Remove a directory from the Index."""
188-
err = C.git_index_remove_directory(self._index, encode_fs_path(path), level)
194+
err = C.git_index_remove_directory(self._index, encode_git_path(path), level)
189195
check_error(err, io=True)
190196

191197
def remove_all(self, pathspecs: typing.Sequence[str | PathLike[str]]) -> None:
@@ -221,7 +227,7 @@ def add(self, path_or_entry: 'IndexEntry | str | PathLike[str]') -> None:
221227
err = C.git_index_add(self._index, centry)
222228
elif isinstance(path_or_entry, str) or hasattr(path_or_entry, '__fspath__'):
223229
path = path_or_entry
224-
err = C.git_index_add_bypath(self._index, encode_fs_path(path))
230+
err = C.git_index_add_bypath(self._index, encode_git_path(path))
225231
else:
226232
raise TypeError('argument must be string, Path or IndexEntry')
227233

@@ -475,7 +481,7 @@ def _to_c(self) -> tuple['ffi.GitIndexEntryC', 'ffi.ArrayC[ffi.char]']:
475481
# basically memcpy()
476482
ffi.buffer(ffi.addressof(centry, 'id'))[:] = self.id.raw[:]
477483
centry.mode = int(self.mode)
478-
path = ffi.new('char[]', encode_fs_path(self.path))
484+
path = ffi.new('char[]', encode_git_path(self.path))
479485
centry.path = path
480486

481487
return centry, path
@@ -503,7 +509,7 @@ def __getitem__(self, path):
503509
ctheirs = ffi.new('git_index_entry **')
504510

505511
err = C.git_index_conflict_get(
506-
cancestor, cours, ctheirs, self._index._index, encode_fs_path(path)
512+
cancestor, cours, ctheirs, self._index._index, encode_git_path(path)
507513
)
508514
check_error(err)
509515

@@ -514,7 +520,7 @@ def __getitem__(self, path):
514520
return ancestor, ours, theirs
515521

516522
def __delitem__(self, path):
517-
err = C.git_index_conflict_remove(self._index._index, encode_fs_path(path))
523+
err = C.git_index_conflict_remove(self._index._index, encode_git_path(path))
518524
check_error(err)
519525

520526
def __iter__(self):
@@ -526,7 +532,7 @@ def __contains__(self, path):
526532
ctheirs = ffi.new('git_index_entry **')
527533

528534
err = C.git_index_conflict_get(
529-
cancestor, cours, ctheirs, self._index._index, encode_fs_path(path)
535+
cancestor, cours, ctheirs, self._index._index, encode_git_path(path)
530536
)
531537
if err == C.GIT_ENOTFOUND:
532538
return False

pygit2/utils.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@
2525

2626
import contextlib
2727
import os
28+
import sys
29+
import unicodedata
2830
from collections.abc import Generator, Iterator, Sequence
2931
from types import TracebackType
3032
from typing import (
@@ -84,6 +86,31 @@ def encode_fs_path(
8486
return os.fsencode(s) # type: ignore[arg-type]
8587

8688

89+
@overload
90+
def encode_git_path(s: PathStrOrBytes) -> bytes: ...
91+
@overload
92+
def encode_git_path(s: 'ffi.NULL_TYPE | None') -> 'ffi.NULL_TYPE': ...
93+
def encode_git_path(
94+
s: 'PathStrOrBytes | ffi.NULL_TYPE | None',
95+
) -> 'bytes | ffi.NULL_TYPE':
96+
"""Encode a path that lives inside a Git repository.
97+
98+
str and PathLike values are encoded as UTF-8 (with surrogateescape for
99+
round-trip of non-UTF-8 bytes). On macOS they are also normalized to NFC,
100+
matching Git's core.precomposeunicode default behaviour.
101+
"""
102+
if s is None or s == ffi.NULL:
103+
return ffi.NULL
104+
105+
if isinstance(s, bytes):
106+
return s
107+
108+
text = os.fspath(s) # type: ignore[arg-type]
109+
if sys.platform == 'darwin':
110+
text = unicodedata.normalize('NFC', text)
111+
return text.encode('utf-8', 'surrogateescape')
112+
113+
87114
# TODO decode_string uses errors='surrogateescape', but encode_string defaults
88115
# to errors='strict', so a value read from libgit2 with bad bytes cannot be
89116
# written back without raising. Decide whether encode_string should default to

src/repository.c

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1824,15 +1824,15 @@ Repository_status(Repository *self, PyObject *args, PyObject *kw)
18241824

18251825

18261826
PyDoc_STRVAR(Repository_status_file__doc__,
1827-
"status_file(path: str) -> enums.FileStatus\n"
1827+
"status_file(path: str | bytes) -> enums.FileStatus\n"
18281828
"\n"
18291829
"Returns the status of the given file path.");
18301830

18311831
PyObject *
18321832
Repository_status_file(Repository *self, PyObject *value)
18331833
{
18341834
PyObject *tvalue;
1835-
char *path = pgit_borrow_fsdefault(value, &tvalue);
1835+
char *path = pgit_borrow_gitpath(value, &tvalue);
18361836
if (!path)
18371837
return NULL;
18381838

src/treebuilder.c

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,7 @@ PyObject *
107107
TreeBuilder_get(TreeBuilder *self, PyObject *py_filename)
108108
{
109109
PyObject *tvalue;
110-
char *filename = pgit_borrow_fsdefault(py_filename, &tvalue);
110+
char *filename = pgit_borrow_gitpath(py_filename, &tvalue);
111111
if (filename == NULL)
112112
return NULL;
113113

@@ -135,7 +135,7 @@ PyObject *
135135
TreeBuilder_remove(TreeBuilder *self, PyObject *py_filename)
136136
{
137137
PyObject *tvalue;
138-
char *filename = pgit_borrow_fsdefault(py_filename, &tvalue);
138+
char *filename = pgit_borrow_gitpath(py_filename, &tvalue);
139139
if (filename == NULL)
140140
return NULL;
141141

src/utils.c

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,101 @@ pgit_borrow_fsdefault(PyObject *value, PyObject **tvalue)
8080
return PyBytes_AS_STRING(bytes);
8181
}
8282

83+
/**
84+
* Return a borrowed C string for a path inside a Git repository.
85+
*
86+
* The input may be a str, bytes, or os.PathLike. str/PathLike values are
87+
* encoded as UTF-8 (with surrogateescape for round-trip of non-UTF-8 bytes).
88+
* On macOS they are also normalized to NFC, matching Git's
89+
* core.precomposeunicode default behaviour. bytes values are returned
90+
* unchanged as raw path bytes.
91+
*/
92+
static PyObject *unicode_normalize = NULL;
93+
94+
static int
95+
ensure_unicode_normalize(void)
96+
{
97+
if (unicode_normalize != NULL) {
98+
return 0;
99+
}
100+
101+
PyObject *mod = PyImport_ImportModule("unicodedata");
102+
if (mod == NULL) {
103+
return -1;
104+
}
105+
106+
unicode_normalize = PyObject_GetAttrString(mod, "normalize");
107+
Py_DECREF(mod);
108+
if (unicode_normalize == NULL) {
109+
return -1;
110+
}
111+
112+
return 0;
113+
}
114+
115+
char*
116+
pgit_borrow_gitpath(PyObject *value, PyObject **tvalue)
117+
{
118+
PyObject *py_path = NULL;
119+
120+
if (PyUnicode_Check(value)) {
121+
py_path = value;
122+
Py_INCREF(py_path);
123+
} else if (PyBytes_Check(value)) {
124+
Py_INCREF(value);
125+
*tvalue = value;
126+
return PyBytes_AsString(value);
127+
} else {
128+
py_path = PyOS_FSPath(value);
129+
if (py_path == NULL) {
130+
return NULL;
131+
}
132+
}
133+
134+
if (PyBytes_Check(py_path)) {
135+
*tvalue = py_path;
136+
return PyBytes_AsString(py_path);
137+
}
138+
139+
#ifdef __APPLE__
140+
if (ensure_unicode_normalize() < 0) {
141+
Py_DECREF(py_path);
142+
return NULL;
143+
}
144+
145+
PyObject *form = PyUnicode_FromString("NFC");
146+
if (form == NULL) {
147+
Py_DECREF(py_path);
148+
return NULL;
149+
}
150+
151+
PyObject *normalized = PyObject_CallFunctionObjArgs(
152+
unicode_normalize, form, py_path, NULL);
153+
Py_DECREF(form);
154+
Py_DECREF(py_path);
155+
if (normalized == NULL) {
156+
return NULL;
157+
}
158+
159+
PyObject *bytes = PyUnicode_AsEncodedString(
160+
normalized, "utf-8", "surrogateescape");
161+
Py_DECREF(normalized);
162+
if (bytes == NULL) {
163+
return NULL;
164+
}
165+
#else
166+
PyObject *bytes = PyUnicode_AsEncodedString(
167+
py_path, "utf-8", "surrogateescape");
168+
Py_DECREF(py_path);
169+
if (bytes == NULL) {
170+
return NULL;
171+
}
172+
#endif
173+
174+
*tvalue = bytes;
175+
return PyBytes_AsString(bytes);
176+
}
177+
83178
/**
84179
* Return a pointer to the underlying C string in 'value'. The pointer is
85180
* guaranteed by 'tvalue', decrease its refcount when done with the string.

src/utils.h

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,7 @@ to_unicode_n(const char *value, size_t len, const char *encoding,
9090
const char* pgit_borrow(PyObject *value);
9191
const char* pgit_borrow_encoding(PyObject *value, const char *encoding, const char *errors, PyObject **tvalue);
9292
char* pgit_borrow_fsdefault(PyObject *value, PyObject **tvalue);
93+
char* pgit_borrow_gitpath(PyObject *value, PyObject **tvalue);
9394
char* pgit_strdup(PyObject *value);
9495

9596

test/test_status.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,3 +124,13 @@ def test_status_file_unicode_normalization(tmp_path: Path, path: str) -> None:
124124
repo.index.add(path)
125125
repo.index.write()
126126
assert repo.status_file(path) == FileStatus.INDEX_NEW
127+
128+
129+
def test_status_file_bytes_path(tmp_path: Path) -> None:
130+
"""status_file must accept raw UTF-8 bytes for a path."""
131+
repo = pygit2.init_repository(str(tmp_path / 'repo'))
132+
path = 'täst_é.txt'
133+
(Path(repo.workdir) / path).write_text('hello')
134+
repo.index.add(path)
135+
repo.index.write()
136+
assert repo.status_file(path.encode('utf-8')) == FileStatus.INDEX_NEW

0 commit comments

Comments
 (0)