Skip to content

Commit 0e952e0

Browse files
codexByron
authored andcommitted
perf(gix): validate reference names without opening Git
Use `gix.Target.Symbolic` for the native full-name grammar before creating any repository or CLI wrapper. Keep the existing operand guard, successful-name cache and per-operation filesystem containment checks. GixPython rejects standalone names that Git accepts with `--allow-onelevel`, including lowercase names, digits, punctuation and some Unicode. Defer native standalone rejections to Git rather than duplicating either grammar in Python. Document GIX-20 as an upstream binding/behavior shortcoming: four audited calls differed (`refs` twice, `hellothere`, `valid_one_level_refname`), and expanded probes include `1`, `A1`, `HEAD_1`, `A-B`, `A.B`, and `Ä`. A fresh supported name now costs 0 CLI launches instead of 2, including the previous wrapper's version probe. Regression assertions cover accepted and rejected native names without a repository and the broader standalone fallback. Fallback decisions are not also counted as completed native validations. Validation: the reference, backend and command-guard selection passed with Gix first (220 tests and 24 subtests, 22.73s), then CLI (175 tests, one skipped and 24 subtests, 44.51s). Ruff lint/format and `git diff --check` passed.
1 parent 3758cc0 commit 0e952e0

4 files changed

Lines changed: 38 additions & 2 deletions

File tree

‎doc/gix-backend.md‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,7 @@ The historical full-suite counts above predate this correction. A constant
133133
| `Repo.rev_parse`, `rev_parse` metadata queries | Revision lookup, common directory, object format, bare flag, worktree root, fixed `modules`/`COMMIT_EDITMSG` locations | Repository-format validation, other metadata paths, symlinked metadata leaves, message searches and describe forms; GIX-1/14/17/22 |
134134
| Tree enumeration, `ls_tree` | Native tree entries with modes, names, and object IDs | Other command options |
135135
| Reference object reads | One exact native lookup for direct, symbolic and absent references | Partial names and native decoding errors |
136+
| Reference-name validation | Native full-name validation without opening a repository | Standalone names outside the native grammar; GIX-20 |
136137
| Managed `symbolic_ref` | Nonrecursive target lookup | Mutation, command-level missing-reference diagnostics |
137138
| Reference enumeration, `for_each_ref` | Sorted reference names and literal prefixes, preserving symbolic aliases | Dangling symbolic refs, root refs, glob patterns, other formats/options |
138139
| Managed `config --get KEY` | Merged repository config snapshot | Files/streams, enumeration, mutation; GIX-12 |
@@ -420,6 +421,7 @@ below use the supplied `pygix` source and its pinned Gitoxide revision.
420421
| GIX-17 | Revision parsing accepts abbreviated IDs with `-dirty`, prefers the OID suffix over an exact describe-shaped tag, and treats escaped braces in message searches differently. | Git-compatible revision grammar and regex semantics. Existing `test_rev_parse.py` cases reproduce all three. |
421422
| GIX-18 | The tree-diff resource cache reads attributes from the index; Git also consults worktree attributes. Native binary classification with textconv configured can also differ even in `to_git` mode. | Independent attribute-source control and `--no-textconv` parity. Statistics currently fall back for any effective `diff` attribute. |
422423
| GIX-19 | GixPython opens repositories with `extensions.compatObjectFormat`, but translation and object-write compatibility have not been validated. The installed Git reports `compatibility hash algorithm support requires Rust` when asked to translate an ID. This is an unvalidated capability guard, not evidence of missing native object mappings. | Verify compatibility-format reads, writes and OID translation against a Git build that supports them. All operations use CLI in the meantime. |
424+
| GIX-20 | `Target.Symbolic(name)` validates full names but rejects standalone names accepted by `git check-ref-format --allow-onelevel`. Four of 430 audited calls differed: `refs` twice, `hellothere`, and `valid_one_level_refname`. Further probes include digits, punctuation and Unicode (`1`, `A1`, `HEAD_1`, `A-B`, `A.B`, `Ä`). | Expose Git-compatible partial/one-level name validation, or correct the native restriction where appropriate. This is a binding/behavior shortcoming to revisit upstream; standalone names rejected natively retain CLI validation. |
423425
| GIX-21 | Native snapshot overrides for `committer.name`, `committer.email` and `gitoxide.commit.committerDate` resolve correctly. Applying them to the retained repository would expose command-specific identity to other threads. GixPython 0.1.0 has no general independent repository clone: Python `copy`/`deepcopy` fail, while the binding's internal `RepoHandle::clone()` shares its `Arc` state. | Bind a cheap independent native clone with isolated config so native identity resolution can apply per-command overrides, including removal semantics. Reopening isolates state but repeats setup; `with_object_memory()` also changes object-write behavior and is not a general clone API. Keep native process/config identity and CLI for differing command overrides. Do not mutate process environment/shared snapshots or resolve identity fields in Python. Explicit canonical commit inputs may still be adapted to `gix.Signature`. |
424426
| GIX-22 | The native Git directory is sufficient for the two fixed metadata leaves used here: `modules` and `COMMIT_EDITMSG`. Git's `path.c` applies no special relocation to either, including in linked worktrees. The adapter appends only these names to the canonical native Git directory. | No new binding is needed for these ordinary paths. Other `--git-path` names and symlinked metadata leaves retain CLI resolution; a general resolver must handle config/environment overrides, common/private storage and canonical targets. `Repository.modules_path()` means `.gitmodules`, not the `modules` storage directory. |
425427

@@ -453,7 +455,7 @@ while Git with `--no-textconv --numstat` reports `-\t-\tfile` (binary).
453455
| Checkout, reset, index checkout/merge, move/remove | No bound equivalent of checkout-index/unpack-trees preserving these worktree/index semantics. Native tree/commit merge is a different operation. |
454456
| Branch/tag porcelain and symbolic-ref mutation | Strict creation, reflogs, config movement, signing, message cleanup and safety contracts need more than a raw ref edit. |
455457
| Object enumeration and alternate-directory listing | No binding for complete ODB object iteration or alternate-store enumeration; `cat-file --batch-all-objects` / `count-objects` remain. |
456-
| Name-rev, check-ref-format, trailer parsing, cherry | No equivalent bound operation. Native describe is not name-rev. Existing validation and parsing remain shared by both installations. |
458+
| Name-rev, trailer parsing, cherry | No equivalent bound operation. Native describe is not name-rev. |
457459
| Config-backed remote/submodule maintenance | Missing config enumeration/multivar/removal operations (GIX-12), plus filesystem and worktree lifecycle requirements. |
458460
| Hook execution | Explicitly requested hooks retain the existing Git-managed behavior. Native operations retain GitPython's default hook/fsmonitor/maintenance restrictions. |
459461

‎git/_backend.py‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -270,6 +270,21 @@ def _ls_tree(repo: Any, args: List[str], kwargs: Dict[str, Any]) -> bytes:
270270
)
271271

272272

273+
def check_ref_name(name: str) -> Any:
274+
"""Validate a full reference name without opening a repository."""
275+
if gix is None:
276+
return NotImplemented
277+
try:
278+
gix.Target.Symbolic(name)
279+
except gix.Error as exc:
280+
if "/" not in name:
281+
return _fallback("Reference.validate", "standalone reference names (GIX-20)")
282+
record("Reference.validate", "native")
283+
raise ValueError("Invalid reference %r" % name) from exc
284+
record("Reference.validate", "native")
285+
return None
286+
287+
273288
def reference_info(command: Any, path: str) -> Any:
274289
"""Read an exact reference target, including an absent reference, in one lookup."""
275290
if gix is None:

‎git/refs/symbolic.py‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -155,7 +155,7 @@ def dereference_recursive(cls, repo: "Repo", ref_path: Union[PathLike, None]) ->
155155

156156
@staticmethod
157157
def _check_ref_name_valid(ref_path: PathLike) -> None:
158-
"""Validate reference names with Git, rejecting CLI control input first."""
158+
"""Validate reference names, rejecting CLI control input first."""
159159
try:
160160
name = Git._check_operand(os.fspath(ref_path), "reference")
161161
except UnsafeOptionError as exc:
@@ -167,6 +167,8 @@ def _check_ref_name_valid(ref_path: PathLike) -> None:
167167
def _check_ref_name_native(name: str, _refresh_token: object) -> None:
168168
# The grammar depends on Git's executable, not repository contents.
169169
# Filesystem containment remains checked separately on every operation.
170+
if _backend.check_ref_name(name) is not NotImplemented:
171+
return
170172
try:
171173
Git()._call_process_safe("check_ref_format", "--allow-onelevel", name)
172174
except GitCommandError as exc:

‎test/test_gix_backend.py‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -253,6 +253,23 @@ def test_native_tree_and_commit_bytes_match_cli(repo):
253253
assert commit.replace(message="a changed message").hexsha == native.hexsha
254254

255255

256+
def test_native_reference_name_validation_without_repository():
257+
SymbolicReference._check_ref_name_native.cache_clear()
258+
count = _backend.statistics().get(("Git.execute", "CLI process"), 0)
259+
with patch.object(Git, "execute", side_effect=AssertionError("unexpected validation CLI call")):
260+
for name in ("HEAD", "FOO", "_", "refs/heads/native-é", "valid/ref/name"):
261+
SymbolicReference._check_ref_name_valid(name)
262+
for name in ("refs/heads/.hidden", "refs/heads/a..b", "refs/heads/end.lock", "--help"):
263+
with pytest.raises(ValueError, match="Invalid reference"):
264+
SymbolicReference._check_ref_name_valid(name)
265+
assert _backend.statistics().get(("Git.execute", "CLI process"), 0) == count
266+
for name in ("refs", "hellothere", "valid_one_level_refname", "1", "123", "A1", "1A", "HEAD_1", "A-B", "A.B", "Ä"):
267+
SymbolicReference._check_ref_name_valid(name)
268+
with pytest.raises(ValueError, match="Invalid reference"):
269+
SymbolicReference._check_ref_name_valid("@")
270+
assert _backend.statistics().get(("Git.execute", "CLI process"), 0) > count
271+
272+
256273
def test_native_reference_reads_include_missing_and_dangling_targets(repo):
257274
repo.index.commit("initial", skip_hooks=True)
258275
repo.git.pack_refs("--all", "--prune")

0 commit comments

Comments
 (0)