Skip to content

Commit 6ed5506

Browse files
committed
Document parity gate and fix fetch refspecs
1 parent 438e308 commit 6ed5506

5 files changed

Lines changed: 177 additions & 30 deletions

File tree

README.md

Lines changed: 44 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -116,11 +116,18 @@ pygit convert-object-format --object-format=sha1 ./sha256-copy ./sha1-copy
116116

117117
## Supported commands
118118

119-
The command surface covers git built-ins plus aliases and pythongit-specific
120-
helpers. CLI parity is tracked against C Git 2.54.0 from the upstream
121-
implementation source: `tools/git_cli_manifest.py` generates
122-
`tests/git_parity/manifest/git-2.54.0.json`. A dedicated CI job builds a
123-
`git version 2.54.0` oracle and runs:
119+
The command surface covers the 147 built-ins from C Git 2.54.0 plus
120+
pythongit-specific helpers. Runtime registration is checked against the
121+
source-derived manifest in
122+
`tests/git_parity/manifest/git-2.54.0.json`, so upstream built-in names such as
123+
`stage`, `pickaxe`, `fsck-objects`, `checkout--worker`,
124+
`credential-cache--daemon`, and `submodule--helper` remain available under the
125+
same spellings as C Git. Pythongit-only helpers are kept separate from the
126+
compatibility surface.
127+
128+
CLI parity is tracked against C Git 2.54.0 from the upstream implementation
129+
source: `tools/git_cli_manifest.py` regenerates the manifest, and a dedicated
130+
CI job builds a `git version 2.54.0` oracle and runs:
124131

125132
```bash
126133
PYGIT_PARITY_GIT=/path/to/git-2.54.0 \
@@ -350,8 +357,8 @@ bodies.
350357
## Testing
351358

352359
```bash
353-
pip install pythongit[test]
354-
pytest
360+
pip install -e ".[test]"
361+
python -m pytest
355362
```
356363

357364
The suite passes:
@@ -366,9 +373,24 @@ The suite passes:
366373
| `unit_integration.py` | end-to-end CLI flows incl. ort-backed conflicts, rename-aware merge, rerere replay, SHA-256 translation, loose cache, streaming upload-pack, recursive tree diff |
367374
| `test_ort_parity.py` | byte-for-byte `ort` parity vs `git merge-tree --write-tree` across every conflict type (content, modify/delete, add/add, rename/rename, rename/delete, directory rename, distinct-types, exec-bit) |
368375
| `unit_phase_scripts.py` | wraps the script-style phase tests |
376+
| `tests/git_parity` | C Git 2.54.0 manifest coverage, exact built-in registry coverage, and oracle behavior comparisons |
369377

370378
Tests that require the real `git` binary are silently skipped when it's not on
371-
PATH, so the suite runs cleanly in containers without one.
379+
PATH, so the normal suite runs cleanly in containers without one. The dedicated
380+
`git-254-parity` CI job is stricter: it builds C Git 2.54.0 from the pinned
381+
upstream tarball, verifies the archive SHA-256, sets `PYGIT_PARITY_GIT`, and
382+
runs the parity tests with `--require-git-254-oracle`.
383+
384+
To run the same required parity gate locally, build or install a `git version
385+
2.54.0` binary and run:
386+
387+
```bash
388+
PYGIT_PARITY_GIT=/path/to/git-2.54.0 \
389+
python -m pytest tests/git_parity --require-git-254-oracle
390+
```
391+
392+
Without `--require-git-254-oracle`, the behavior-comparison tests skip when the
393+
oracle is unavailable, while manifest and registry coverage still run.
372394

373395
The pure-Python `ort` engine is additionally cross-checked against C Git with
374396
the differential fuzzers in `tests/diff_xdiff_harness.py` (blob-level 3-way
@@ -411,16 +433,20 @@ randomized cases.
411433
## Contributing
412434

413435
The project tries to follow git's published wire and on-disk format specs
414-
(`Documentation/gitformat-*.adoc`, `Documentation/technical/*.adoc`). When
415-
adding a feature:
416-
417-
1. Find the matching `builtin/<name>.c` and read its argument parser to figure
418-
out the flag set people actually use.
419-
2. Implement the behavior, but only the common flags first. Less-common flags
420-
should `argparse.error` rather than silently misbehave.
421-
3. Add a unit test in `tests/unit_*.py`. If real `git` can verify the output,
422-
also add an interop check.
423-
4. Run `pytest` — must remain green.
436+
(`Documentation/gitformat-*.adoc`, `Documentation/technical/*.adoc`) and C Git
437+
2.54.0's implementation behavior. When adding or changing CLI behavior:
438+
439+
1. Find the matching C implementation in the Git 2.54.0 source and compare its
440+
parser, setup flags, output, exit codes, and repository mutations.
441+
2. Regenerate or inspect `tests/git_parity/manifest/git-2.54.0.json` with
442+
`tools/git_cli_manifest.py` when the upstream source target changes.
443+
3. Keep every C Git built-in registered under its exact 2.54.0 name; classify
444+
any pythongit-only command in `_PYGIT_EXTENSION_COMMANDS`.
445+
4. Add focused unit/integration tests plus a `tests/git_parity` oracle case for
446+
user-visible CLI behavior whenever the command can be exercised
447+
deterministically.
448+
5. Run `python -m pytest`; when a Git 2.54.0 binary is available, also run the
449+
required parity gate shown above.
424450

425451
## License
426452

pythongit/cli.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1310,10 +1310,11 @@ def cmd_stash(argv: list[str]) -> int:
13101310
def cmd_fetch(argv: list[str]) -> int:
13111311
ap = argparse.ArgumentParser(prog="pygit fetch")
13121312
ap.add_argument("remote", nargs="?", default="origin")
1313+
ap.add_argument("refspecs", nargs="*")
13131314
args = ap.parse_args(argv)
13141315
repo = _repo()
13151316
from . import protocol
1316-
updated = protocol.fetch(repo, args.remote)
1317+
updated = protocol.fetch(repo, args.remote, args.refspecs or None)
13171318
for ref, sha in updated.items():
13181319
_print(f" * {ref} -> {sha[:7]}")
13191320
if not updated:

pythongit/protocol.py

Lines changed: 72 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -371,10 +371,46 @@ def clone(url: str, target_dir: str, object_format: str | None = None) -> Reposi
371371

372372

373373
# ---------------------------------------------------------------------------
374-
# fetch — like clone, but updates remote-tracking refs only.
374+
# fetch — like clone; default fetch updates remote-tracking refs, while
375+
# explicit branch refspecs are fetched to FETCH_HEAD unless a destination is
376+
# given.
375377

376378

377-
def fetch(repo: Repository, remote: str = "origin") -> dict[str, str]:
379+
def _resolve_remote_fetch_ref(remote_refs: dict[str, str], source: str) -> tuple[str, str]:
380+
candidates = [source]
381+
if not source.startswith("refs/"):
382+
candidates.extend((f"refs/heads/{source}", f"refs/tags/{source}"))
383+
for name in candidates:
384+
sha = remote_refs.get(name)
385+
if sha:
386+
return name, sha
387+
raise RuntimeError(f"couldn't find remote ref {source}")
388+
389+
390+
def _normalize_fetch_destination(remote_name: str, destination: str) -> str:
391+
if destination.startswith("refs/"):
392+
return destination
393+
if remote_name.startswith("refs/tags/"):
394+
return f"refs/tags/{destination}"
395+
return f"refs/heads/{destination}"
396+
397+
398+
def _fetch_head_note(remote_name: str, url: str) -> str:
399+
if remote_name == "HEAD":
400+
return f"'{remote_name}' of {url}"
401+
if remote_name.startswith("refs/heads/"):
402+
return f"branch '{remote_name[len('refs/heads/'):]}' of {url}"
403+
if remote_name.startswith("refs/tags/"):
404+
return f"tag '{remote_name[len('refs/tags/'):]}' of {url}"
405+
return f"'{remote_name}' of {url}"
406+
407+
408+
def _write_fetch_head(repo: Repository, entries: list[tuple[str, str]], url: str) -> None:
409+
lines = [f"{sha}\t\t{_fetch_head_note(name, url)}\n" for name, sha in entries]
410+
(repo.gitdir / "FETCH_HEAD").write_text("".join(lines), encoding="utf-8")
411+
412+
413+
def fetch(repo: Repository, remote: str = "origin", refspecs: list[str] | None = None) -> dict[str, str]:
378414
from . import pack as pack_mod
379415
from . import refs as refs_mod
380416
cp = repo.config()
@@ -389,7 +425,19 @@ def fetch(repo: Repository, remote: str = "origin") -> dict[str, str]:
389425
s = refs_mod.read_ref(repo, f"refs/heads/{b}")
390426
if s:
391427
haves.append(s)
392-
wants = sorted(set(remote_refs.values()) - set(haves))
428+
explicit: list[tuple[str, str, str | None]] = []
429+
if refspecs:
430+
for raw in refspecs:
431+
spec = raw[1:] if raw.startswith("+") else raw
432+
source, has_destination, destination = spec.partition(":")
433+
if not source:
434+
continue
435+
remote_name, sha = _resolve_remote_fetch_ref(remote_refs, source)
436+
dst_ref = _normalize_fetch_destination(remote_name, destination) if has_destination and destination else None
437+
explicit.append((remote_name, sha, dst_ref))
438+
wants = sorted({sha for _name, sha, _dst in explicit} - set(haves))
439+
else:
440+
wants = sorted(set(remote_refs.values()) - set(haves))
393441
updated: dict[str, str] = {}
394442
if wants:
395443
tmp_pack = None
@@ -409,15 +457,28 @@ def fetch(repo: Repository, remote: str = "origin") -> dict[str, str]:
409457
os.unlink(str(Path(tmp_pack).with_suffix(".idx")))
410458
except OSError:
411459
pass
460+
if explicit:
461+
fetch_head_entries = []
462+
for name, sha, dst_ref in explicit:
463+
if dst_ref:
464+
cur = refs_mod.read_ref(repo, dst_ref)
465+
if cur != sha:
466+
refs_mod.update_ref(repo, dst_ref, sha, message=f"fetch {remote}")
467+
updated[dst_ref] = sha
468+
else:
469+
fetch_head_entries.append((name, sha))
470+
updated["FETCH_HEAD"] = sha
471+
if fetch_head_entries:
472+
_write_fetch_head(repo, fetch_head_entries, url)
473+
return updated
412474
for name, sha in remote_refs.items():
413-
if not name.startswith("refs/heads/"):
414-
continue
415-
branch = name[len("refs/heads/"):]
416-
ref = f"refs/remotes/{remote}/{branch}"
417-
cur = refs_mod.read_ref(repo, ref)
418-
if cur != sha:
419-
refs_mod.update_ref(repo, ref, sha, message=f"fetch {remote}")
420-
updated[ref] = sha
475+
if name.startswith("refs/heads/"):
476+
branch = name[len("refs/heads/"):]
477+
ref = f"refs/remotes/{remote}/{branch}"
478+
cur = refs_mod.read_ref(repo, ref)
479+
if cur != sha:
480+
refs_mod.update_ref(repo, ref, sha, message=f"fetch {remote}")
481+
updated[ref] = sha
421482
return updated
422483

423484

tests/unit_integration.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -369,6 +369,27 @@ def test_remote_verbose_option(tmprepo, capsys):
369369
assert "origin\thttps://example.com/repo.git (push)" in out
370370

371371

372+
def test_fetch_accepts_explicit_refspec(tmprepo, monkeypatch, capsys):
373+
from pythongit import protocol
374+
375+
repo, _ = tmprepo
376+
captured = {}
377+
378+
def fake_fetch(fetch_repo, remote="origin", refspecs=None):
379+
captured["repo"] = fetch_repo
380+
captured["remote"] = remote
381+
captured["refspecs"] = refspecs
382+
return {"FETCH_HEAD": "a" * repo.hex_len}
383+
384+
monkeypatch.setattr(protocol, "fetch", fake_fetch)
385+
386+
assert cli_run("fetch", "origin", "main") == 0
387+
assert captured["repo"].path == repo.path
388+
assert captured["remote"] == "origin"
389+
assert captured["refspecs"] == ["main"]
390+
assert "FETCH_HEAD" in capsys.readouterr().out
391+
392+
372393
def test_global_options_before_command(tmprepo, capsys):
373394
repo, path = tmprepo
374395
cwd = os.getcwd()

tests/unit_modules.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,44 @@ def test_protocol_auth_header_uses_github_token_env(tmp_path, monkeypatch):
205205
assert header == "Basic eC1hY2Nlc3MtdG9rZW46c2VjcmV0LXRva2Vu"
206206

207207

208+
def test_protocol_fetch_explicit_branch_writes_fetch_head(tmprepo, monkeypatch):
209+
from pythongit import refs
210+
from pythongit import pack as pack_mod
211+
212+
repo, _ = tmprepo
213+
cfg = repo.gitdir / "config"
214+
cfg.write_text(
215+
cfg.read_text(encoding="utf-8")
216+
+ '[remote "origin"]\n'
217+
+ "\turl = https://example.com/repo.git\n"
218+
+ "\tfetch = +refs/heads/*:refs/remotes/origin/*\n",
219+
encoding="utf-8",
220+
)
221+
main_sha = "a" * repo.hex_len
222+
other_sha = "b" * repo.hex_len
223+
wanted = []
224+
225+
monkeypatch.setattr(
226+
protocol_mod,
227+
"discover_refs",
228+
lambda _url: {"refs/heads/main": main_sha, "refs/heads/other": other_sha},
229+
)
230+
231+
def fake_fetch_pack_to_file(_url, wants, _path, haves=None):
232+
wanted.extend(wants)
233+
return 0
234+
235+
monkeypatch.setattr(protocol_mod, "fetch_pack_to_file", fake_fetch_pack_to_file)
236+
monkeypatch.setattr(pack_mod, "install_pack_file", lambda _repo, _path: None)
237+
238+
updated = protocol_mod.fetch(repo, "origin", ["main"])
239+
240+
assert updated == {"FETCH_HEAD": main_sha}
241+
assert wanted == [main_sha]
242+
assert refs.read_ref(repo, "refs/remotes/origin/main") is None
243+
assert "branch 'main' of https://example.com/repo.git" in (repo.gitdir / "FETCH_HEAD").read_text()
244+
245+
208246
# --- rerere ---------------------------------------------------------------
209247

210248

0 commit comments

Comments
 (0)