mirror of
https://github.com/norandom/OpenUltraSAST.git
synced 2026-10-16 03:49:26 +00:00
feat(pre-push-safety-net): resolve immutable push comparisons
This commit is contained in:
@@ -142,3 +142,18 @@ The content review checked boundary ownership, failure handling, vendor exclusio
|
||||
and independent finding/coverage/push status. Draft defaults remain clearly labeled. Runtime
|
||||
integration, performance and capability admission remain unverified work; no code tests were
|
||||
needed for these documentation-only amendments.
|
||||
|
||||
### Group 2 implementation: immutable Git reads
|
||||
|
||||
The local Git runtime is 2.43.0. Snapshot reads must suppress replacement objects
|
||||
and implicit promisor fetching, including on Git versions that predate newer
|
||||
lazy-fetch controls. The protocol allow-list is the stronger boundary: an empty
|
||||
`GIT_ALLOW_PROTOCOL` forbids transports even when repository configuration permits
|
||||
an individual protocol. Git documents this precedence in its
|
||||
[environment-variable reference](https://git-scm.com/docs/git#Documentation/git.txt-GITALLOWPROTOCOL).
|
||||
Regression fixtures must prove supplied-object identity and lack of helper execution;
|
||||
setting environment variables alone is not evidence of either property.
|
||||
|
||||
The snapshot adapter provides change evidence, not a second region selector. Source
|
||||
materialization, changed spans, and correspondence must remain language agnostic;
|
||||
unknown parser or correspondence boundaries cannot become a successful negative.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"feature_name": "pre-push-safety-net",
|
||||
"created_at": "2026-09-12T15:35:00Z",
|
||||
"updated_at": "2026-09-12T17:21:05.783007Z",
|
||||
"updated_at": "2026-09-12T18:32:19.033933Z",
|
||||
"language": "en",
|
||||
"phase": "implementation",
|
||||
"approvals": {
|
||||
@@ -19,7 +19,7 @@
|
||||
}
|
||||
},
|
||||
"ready_for_implementation": true,
|
||||
"implementation_approval_note": "2026-09-12: maintainer explicitly said 'approve.' in response to approval of the generated task plan and starting implementation with group 1. All tasks are approved; this implementation run starts with tasks 1.1\u20131.4. No task is complete until its fresh-evidence and reviewer gates pass.",
|
||||
"implementation_approval_note": "Groups 1 and 2 explicitly authorized by the maintainer. Group 1 verified complete; this run implements group 2 (2.1\u20132.3). Remaining task plan retains approval, with execution sequenced by the spec.",
|
||||
"depends_on": [
|
||||
"contributor-scan"
|
||||
],
|
||||
@@ -40,6 +40,11 @@
|
||||
"at": "2026-09-12T16:20:40.814596Z",
|
||||
"source": "approve.",
|
||||
"action": "Accepted the generated task plan and authorized starting group 1 (1.1\u20131.4). Implementation started; tasks require independent review and fresh verification before completion."
|
||||
},
|
||||
{
|
||||
"at": "2026-09-12T18:32:19.033933Z",
|
||||
"source": "ok, next kiro phase, approved; correct. we can continue with the spec",
|
||||
"action": "Authorized group 2 (2.1\u20132.3), resumed after local Claude Mem repair. Independent review and fresh verification remain required."
|
||||
}
|
||||
],
|
||||
"task_review": {
|
||||
@@ -60,6 +65,7 @@
|
||||
],
|
||||
"remaining_tasks": 23,
|
||||
"verification_record": "implementation-group-1.md",
|
||||
"next_group": 2
|
||||
"next_group": 2,
|
||||
"active_group": 2
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,12 +56,13 @@ the hook and capability admission are not yet implemented. See `implementation-g
|
||||
- _Verified 2026-09-12:_ independent review APPROVED; 965 tests passed, 9 skipped; touched-source Ruff/mypy passed. Parent rebuilt the local image and verified non-root, network-disabled CLI startup, all contract imports, an 83-byte configuration round trip and invalid-budget rejection. Comparison-owned completion preserves different bases for a shared head. Contracts are structural; runtime snapshot/cache/runner/admission implementations remain in later tasks.
|
||||
|
||||
- [ ] 2. Resolve immutable pushed content
|
||||
- [ ] 2.1 Resolve every supplied ref update and comparison
|
||||
- [x] 2.1 Resolve every supplied ref update and comparison
|
||||
- Parse Git's complete update input using object-format-aware validation; resolve existing tips, new-branch configured bases, force-pushes, merges, peeled tags, deleted refs and unsupported objects.
|
||||
- Deduplicate equal targets without merging different base comparisons; retain every ref association. Do not silently substitute HEAD or fetch missing objects.
|
||||
- Done when multi-ref and missing-base fixtures produce exact comparison identities and explicit dispositions, including deletion-only no-analysis results.
|
||||
- _Boundary: Snapshot Adapter_
|
||||
- _Requirements: 1.1, 1.3, 1.4, 7.1_
|
||||
- _Verified 2026-09-12:_ independent review APPROVED after Unicode-ref repair; 997 tests passed, 9 skipped; focused 76 passed and touched Ruff/mypy passed. Real SHA-1/SHA-256 fixtures cover exact comparisons, tags, deletion, missing/ambiguous bases, replacement/graft suppression and blocked promisor fetch. Parent read 2,547 pinned source bytes and verified real-repository comparison/ref identity with dirty state and index preserved. ASCII protocol framing retains valid Unicode ref names.
|
||||
- [ ] 2.2 Materialize isolated object snapshots
|
||||
- Read immutable tracked blobs into bounded scratch storage using machine-readable paths; do not checkout into the live tree or run filters, project scripts or network requests.
|
||||
- Preserve path identity and report symlinks, gitlinks, unavailable LFS blobs and unsupported content; never follow source outside the snapshot.
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
{
|
||||
"counts": {
|
||||
"load_bearing": 97,
|
||||
"load_bearing": 98,
|
||||
"standalone_capability": 9
|
||||
},
|
||||
"modules": 106,
|
||||
"note": "Refreshed 2026-09-12 after pre-push-safety-net task 1.4: immutable push and generic engine contracts are spec-owned; runtime integration remains pending.",
|
||||
"modules": 107,
|
||||
"note": "Refreshed 2026-09-12 after pre-push-safety-net task 2.1: local immutable ref resolution is spec-owned; runner integration remains pending.",
|
||||
"orphaned": [],
|
||||
"rows": [
|
||||
{
|
||||
@@ -633,16 +633,26 @@
|
||||
},
|
||||
{
|
||||
"classification": "load_bearing",
|
||||
"importers": [],
|
||||
"importers": [
|
||||
"push.snapshot"
|
||||
],
|
||||
"module": "push",
|
||||
"reason": "pre-push-safety-net Req 5.5/7.3: experimental push contract package; no hook installed"
|
||||
},
|
||||
{
|
||||
"classification": "load_bearing",
|
||||
"importers": [],
|
||||
"importers": [
|
||||
"push.snapshot"
|
||||
],
|
||||
"module": "push.contracts",
|
||||
"reason": "pre-push-safety-net Req 5.5/7.3: immutable comparisons and independent result statuses; runner pending"
|
||||
},
|
||||
{
|
||||
"classification": "load_bearing",
|
||||
"importers": [],
|
||||
"module": "push.snapshot",
|
||||
"reason": "pre-push-safety-net Req 1.1/1.3/1.4/7.1: local immutable ref resolution; runner integration pending"
|
||||
},
|
||||
{
|
||||
"classification": "standalone_capability",
|
||||
"importers": [],
|
||||
@@ -1125,6 +1135,5 @@
|
||||
"skills": "skill router; consumed by mcp and by the OpenCode integration, not by the scan path",
|
||||
"slot_contract": "the slot/contract protocol stage_processors implements",
|
||||
"stage_processors": "HarnessX slot-contracted scan stages; pinned by the zero-dependency guard in test_gate"
|
||||
},
|
||||
"updated_at": "2026-09-12"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ ROOTS = ("gate", "map_gate", "pair_gate", "cli", "__main__")
|
||||
|
||||
# Modules that stand alone: their own CLI subcommand or external protocol, deliberately off the scan path.
|
||||
SPEC_OWNED = {
|
||||
"push.snapshot": "pre-push-safety-net Req 1.1/1.3/1.4/7.1: local immutable ref resolution; runner integration pending",
|
||||
"contracts": "pre-push-safety-net Req 7.3: strict shared manifest serialization, independent of push",
|
||||
"model.contracts": "pre-push-safety-net Req 4.1/7.3: generic deadline, change and exact scope contracts; integration pending",
|
||||
"cpg.artifact": "pre-push-safety-net Req 4.3/7.3: backend graph identity and live lease contract; validation integration pending",
|
||||
|
||||
@@ -84,3 +84,23 @@ class PushResult(Contract):
|
||||
raise ValueError("aggregate complete coverage requires completed comparison analyses")
|
||||
if self.coverage_status == "not_applicable" and (self.analyses or self.actionable_defect_ids):
|
||||
raise ValueError("not applicable cannot carry analyzed scope or findings")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class UpdateResolution(Contract):
|
||||
"""One record per supplied line, including repeated ref associations."""
|
||||
|
||||
update: PushUpdate
|
||||
disposition: Literal["ready", "deleted", "missing_target", "unsupported_target", "missing_base", "unsupported_base",
|
||||
"no_merge_base", "ambiguous_base", "resolution_error"]
|
||||
head_oid: str | None
|
||||
base_oid: str | None
|
||||
base_reason: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PushResolution(Contract):
|
||||
object_format: str
|
||||
updates: tuple[UpdateResolution, ...]
|
||||
comparisons: tuple[PushComparison, ...]
|
||||
targets: tuple[str, ...]
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
"""Resolve supplied push tips using local Git objects, never the live checkout.
|
||||
|
||||
No fetch, replacement objects, checkout, filters or project commands are used.
|
||||
Materialization and changed spans are separate subsequent snapshot-adapter tasks.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
from dataclasses import replace
|
||||
from pathlib import Path
|
||||
|
||||
from openultrasast.push.contracts import PushComparison, PushResolution, PushUpdate, UpdateResolution
|
||||
|
||||
|
||||
class SnapshotInputError(ValueError):
|
||||
"""Malformed protocol input or an unavailable repository, never an empty success."""
|
||||
|
||||
|
||||
class SnapshotAdapter:
|
||||
def __init__(self, repository: Path, *, comparison_base: str | None = None) -> None:
|
||||
self.repository = repository.resolve()
|
||||
self.comparison_base = comparison_base
|
||||
# Hook-inherited Git variables may redirect objects or worktrees elsewhere.
|
||||
self._env = {key: value for key, value in os.environ.items() if not key.startswith("GIT_")}
|
||||
self._env.update(GIT_NO_REPLACE_OBJECTS="1", GIT_NO_LAZY_FETCH="1", GIT_TERMINAL_PROMPT="0",
|
||||
GIT_CONFIG_NOSYSTEM="1", GIT_CONFIG_GLOBAL=os.devnull, GIT_ALLOW_PROTOCOL="", GIT_OPTIONAL_LOCKS="0",
|
||||
GIT_GRAFT_FILE=os.devnull)
|
||||
self.object_format = self._required("rev-parse", "--show-object-format=storage")
|
||||
# Ask the installed Git for its object ID width rather than assuming SHA-1.
|
||||
self._oid_width = len(self._required("hash-object", "--stdin", input=""))
|
||||
self._zero = "0" * self._oid_width
|
||||
|
||||
def _git(self, *args: str, input: str | None = None) -> subprocess.CompletedProcess[str]:
|
||||
try:
|
||||
return subprocess.run(
|
||||
["git", "-c", "protocol.allow=never", "-c", "core.hooksPath=" + os.devnull,
|
||||
"-C", str(self.repository), *args],
|
||||
input=input, capture_output=True, text=True, encoding="utf-8", errors="surrogateescape",
|
||||
env=self._env, timeout=10, check=False,
|
||||
)
|
||||
except (OSError, subprocess.TimeoutExpired) as error:
|
||||
raise SnapshotInputError(f"local Git resolution failed: {type(error).__name__}") from error
|
||||
|
||||
def _required(self, *args: str, input: str | None = None) -> str:
|
||||
result = self._git(*args, input=input)
|
||||
if result.returncode:
|
||||
raise SnapshotInputError(f"local Git {args[0]} failed (exit {result.returncode})")
|
||||
return result.stdout.strip()
|
||||
|
||||
def parse_updates(self, data: str) -> tuple[PushUpdate, ...]:
|
||||
"""Validate every record before attempting resolution; malformed input fails closed."""
|
||||
updates = []
|
||||
# Git frames records with ASCII LF and fields with ASCII SP. Unicode
|
||||
# whitespace is legal inside refs and must never be stripped or split.
|
||||
records = data.split("\n") if data else []
|
||||
if records and records[-1] == "":
|
||||
records.pop()
|
||||
for number, record in enumerate(records, 1):
|
||||
parts = record.split(" ")
|
||||
if len(parts) != 4 or any(not part for part in parts) or any(ord(char) < 32 for char in record):
|
||||
raise SnapshotInputError(f"invalid pre-push update at line {number}")
|
||||
local_ref, local_oid, remote_ref, remote_oid = parts
|
||||
if any(re.fullmatch(r"[0-9a-f]{" + str(self._oid_width) + r"}", oid) is None
|
||||
for oid in (local_oid, remote_oid)):
|
||||
raise SnapshotInputError(f"invalid {self.object_format} object ID at line {number}")
|
||||
if self._git("check-ref-format", remote_ref).returncode or not remote_ref.startswith("refs/"):
|
||||
raise SnapshotInputError(f"invalid destination ref at line {number}")
|
||||
if (local_ref == "(delete)") != (local_oid == self._zero) or (local_oid == remote_oid == self._zero):
|
||||
raise SnapshotInputError(f"invalid deletion update at line {number}")
|
||||
updates.append(PushUpdate(*parts))
|
||||
return tuple(updates)
|
||||
|
||||
def _commit(self, oid: str) -> tuple[str | None, str]:
|
||||
seen: set[str] = set()
|
||||
while oid not in seen:
|
||||
seen.add(oid)
|
||||
kind = self._git("cat-file", "-t", oid)
|
||||
if kind.returncode:
|
||||
return None, "missing"
|
||||
object_type = kind.stdout.strip()
|
||||
if object_type not in ("commit", "tag"):
|
||||
return None, "unsupported"
|
||||
content = self._git("cat-file", object_type, oid)
|
||||
if content.returncode:
|
||||
return None, "missing"
|
||||
if object_type == "commit":
|
||||
return oid, "commit"
|
||||
first = content.stdout.splitlines()[0] if content.stdout else ""
|
||||
if re.fullmatch(r"object [0-9a-f]{" + str(self._oid_width) + r"}", first) is None:
|
||||
return None, "unsupported"
|
||||
oid = first.removeprefix("object ")
|
||||
return None, "unsupported"
|
||||
|
||||
def _resolve(self, update: PushUpdate) -> UpdateResolution:
|
||||
if update.local_oid == self._zero:
|
||||
return UpdateResolution(update, "deleted", None, None, "ref_deleted_no_analysis")
|
||||
head, kind = self._commit(update.local_oid)
|
||||
if head is None:
|
||||
return UpdateResolution(update, "missing_target" if kind == "missing" else "unsupported_target",
|
||||
None, None, "target_not_a_local_commit")
|
||||
if update.remote_oid != self._zero:
|
||||
base, kind = self._commit(update.remote_oid)
|
||||
disposition = "ready" if base else ("missing_base" if kind == "missing" else "unsupported_base")
|
||||
return UpdateResolution(update, disposition, head, base, "supplied_remote_tip") # type: ignore[arg-type]
|
||||
reason = "configured_unique_merge_base"
|
||||
if not self.comparison_base:
|
||||
return UpdateResolution(update, "missing_base", head, None, "new_ref_no_configured_base")
|
||||
configured = self._git("rev-parse", "--verify", "--end-of-options", self.comparison_base)
|
||||
if configured.returncode:
|
||||
return UpdateResolution(update, "missing_base", head, None, "configured_base_unavailable")
|
||||
anchor, kind = self._commit(configured.stdout.strip())
|
||||
if anchor is None:
|
||||
return UpdateResolution(update, "missing_base" if kind == "missing" else "unsupported_base",
|
||||
head, None, "configured_base_not_a_local_commit")
|
||||
reason += ":" + anchor
|
||||
merged = self._git("merge-base", "--all", anchor, head)
|
||||
if merged.returncode not in (0, 1):
|
||||
return UpdateResolution(update, "resolution_error", head, None, reason)
|
||||
bases = merged.stdout.splitlines()
|
||||
if len(bases) != 1:
|
||||
return UpdateResolution(update, "ambiguous_base" if bases else "no_merge_base", head, None, reason)
|
||||
base, kind = self._commit(bases[0])
|
||||
return UpdateResolution(update, "ready" if base else "missing_base", head, base, reason)
|
||||
|
||||
def resolve_updates(self, data: str) -> PushResolution:
|
||||
updates = self.parse_updates(data)
|
||||
records: list[UpdateResolution] = []
|
||||
comparisons: list[PushComparison] = []
|
||||
targets: list[str] = []
|
||||
for update in updates:
|
||||
try:
|
||||
record = self._resolve(update)
|
||||
except SnapshotInputError:
|
||||
record = UpdateResolution(update, "resolution_error", None, None, "local_git_resolution_failed")
|
||||
records.append(record)
|
||||
if record.head_oid is None:
|
||||
continue
|
||||
if record.head_oid not in targets:
|
||||
targets.append(record.head_oid)
|
||||
# Choice provenance is part of comparison identity, not just the target graph.
|
||||
key = (record.head_oid, record.base_oid, record.base_reason)
|
||||
for index, comparison in enumerate(comparisons):
|
||||
if (comparison.head_oid, comparison.base_oid, comparison.base_reason) == key:
|
||||
if update.remote_ref not in comparison.refs:
|
||||
comparisons[index] = replace(comparison, refs=(*comparison.refs, update.remote_ref))
|
||||
break
|
||||
else:
|
||||
comparisons.append(PushComparison(*key, refs=(update.remote_ref,)))
|
||||
return PushResolution(self.object_format, tuple(records), tuple(comparisons), tuple(targets))
|
||||
@@ -0,0 +1,207 @@
|
||||
"""Exercise the snapshot resolver with real local Git objects, not mocked answers."""
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from openultrasast.push.snapshot import SnapshotAdapter
|
||||
|
||||
|
||||
def git(repo: Path, *args: str, input: str | None = None) -> str:
|
||||
result = subprocess.run(["git", "-C", str(repo), *args], input=input, text=True, capture_output=True, check=True)
|
||||
return result.stdout.strip()
|
||||
|
||||
|
||||
@pytest.fixture(params=["sha1", "sha256"])
|
||||
def repo(tmp_path: Path, request: pytest.FixtureRequest) -> Path:
|
||||
git(tmp_path, "init", "--object-format=" + request.param)
|
||||
git(tmp_path, "config", "user.name", "Fixture")
|
||||
git(tmp_path, "config", "user.email", "[email protected]")
|
||||
return tmp_path
|
||||
|
||||
|
||||
def commit(repo: Path, label: str, *parents: str) -> str:
|
||||
blob = git(repo, "hash-object", "-w", "--stdin", input=label)
|
||||
assert git(repo, "cat-file", "-s", blob) == str(len(label))
|
||||
tree = git(repo, "mktree", input=f"100644 blob {blob}\tfile.js\n")
|
||||
args = ["commit-tree", tree]
|
||||
for parent in parents:
|
||||
args.extend(["-p", parent])
|
||||
return git(repo, *args, input=label)
|
||||
|
||||
|
||||
def line(head: str, base: str, name: str = "main") -> str:
|
||||
return f"refs/heads/{name} {head} refs/heads/{name} {base}\n"
|
||||
|
||||
|
||||
def test_preserves_every_ref_and_distinct_comparisons(repo: Path) -> None:
|
||||
base = commit(repo, "base")
|
||||
other = commit(repo, "other", base)
|
||||
head = commit(repo, "head", base)
|
||||
result = SnapshotAdapter(repo).resolve_updates(line(head, base) + line(head, other, "force") + line(head, base, "alias"))
|
||||
assert result.targets == (head,)
|
||||
assert [(c.head_oid, c.base_oid, c.refs) for c in result.comparisons] == [
|
||||
(head, base, ("refs/heads/main", "refs/heads/alias")), (head, other, ("refs/heads/force",))]
|
||||
assert [r.disposition for r in result.updates] == ["ready"] * 3
|
||||
assert len(result.updates) == 3
|
||||
|
||||
|
||||
def test_configured_new_branch_uses_unique_merge_base(repo: Path) -> None:
|
||||
base = commit(repo, "base")
|
||||
left, right = commit(repo, "left", base), commit(repo, "right", base)
|
||||
git(repo, "update-ref", "refs/heads/baseline", right)
|
||||
merge = commit(repo, "merge", left, right)
|
||||
result = SnapshotAdapter(repo, comparison_base="refs/heads/baseline").resolve_updates(line(merge, "0" * len(base)))
|
||||
assert result.comparisons[0].base_oid == right
|
||||
assert result.comparisons[0].head_oid == merge
|
||||
assert result.updates[0].base_reason == "configured_unique_merge_base:" + right
|
||||
assert result.updates[0].disposition == "ready"
|
||||
|
||||
|
||||
def test_no_implicit_head_or_fetch_for_missing_base(repo: Path) -> None:
|
||||
head = commit(repo, "head")
|
||||
git(repo, "update-ref", "HEAD", head)
|
||||
for configured in (None, "refs/remotes/origin/absent"):
|
||||
result = SnapshotAdapter(repo, comparison_base=configured).resolve_updates(line(head, "0" * len(head)))
|
||||
assert result.updates[0].disposition == "missing_base"
|
||||
assert result.comparisons[0].base_oid is None
|
||||
result = SnapshotAdapter(repo).resolve_updates(line(head, "1" * len(head)))
|
||||
assert result.updates[0].disposition == "missing_base"
|
||||
assert result.comparisons[0].base_reason == "supplied_remote_tip"
|
||||
|
||||
|
||||
def test_unrelated_and_ambiguous_new_branch_bases(repo: Path) -> None:
|
||||
root = commit(repo, "root")
|
||||
unrelated = commit(repo, "unrelated")
|
||||
result = SnapshotAdapter(repo, comparison_base=unrelated).resolve_updates(line(root, "0" * len(root)))
|
||||
assert result.updates[0].disposition == "no_merge_base"
|
||||
left, right = commit(repo, "left", root), commit(repo, "right", root)
|
||||
one, two = commit(repo, "one", left, right), commit(repo, "two", right, left)
|
||||
result = SnapshotAdapter(repo, comparison_base=one).resolve_updates(line(two, "0" * len(root)))
|
||||
assert result.updates[0].disposition == "ambiguous_base"
|
||||
assert result.comparisons[0].base_oid is None
|
||||
|
||||
|
||||
def test_tags_peel_to_commit_and_retain_input_identity(repo: Path) -> None:
|
||||
base, head = commit(repo, "base"), commit(repo, "head")
|
||||
git(repo, "tag", "-a", "old", base, "-m", "old")
|
||||
git(repo, "tag", "-a", "new", head, "-m", "new")
|
||||
old_tag, new_tag = git(repo, "rev-parse", "old"), git(repo, "rev-parse", "new")
|
||||
data = f"refs/tags/new {new_tag} refs/tags/new {old_tag}\n" + line(head, base)
|
||||
result = SnapshotAdapter(repo).resolve_updates(data)
|
||||
assert result.targets == (head,)
|
||||
assert result.comparisons[0].base_oid == base
|
||||
assert result.comparisons[0].refs == ("refs/tags/new", "refs/heads/main")
|
||||
assert result.updates[0].update.local_oid == new_tag
|
||||
assert result.updates[0].update.remote_oid == old_tag
|
||||
assert type(result).from_payload(result.to_payload()) == result
|
||||
|
||||
|
||||
def test_deletion_only_has_no_analysis(repo: Path) -> None:
|
||||
old = commit(repo, "old")
|
||||
result = SnapshotAdapter(repo).resolve_updates(f"(delete) {'0' * len(old)} refs/heads/old {old}\n")
|
||||
assert result.targets == result.comparisons == ()
|
||||
assert result.updates[0].disposition == "deleted"
|
||||
assert SnapshotAdapter(repo).resolve_updates("").updates == ()
|
||||
|
||||
|
||||
def test_missing_and_unsupported_objects_are_explicit(repo: Path) -> None:
|
||||
head = commit(repo, "head")
|
||||
blob = git(repo, "hash-object", "-w", "--stdin", input="unsupported source")
|
||||
git(repo, "tag", "-a", "blob-tag", blob, "-m", "unsupported")
|
||||
tag = git(repo, "rev-parse", "blob-tag")
|
||||
result = SnapshotAdapter(repo).resolve_updates(line("1" * len(head), head) + line(blob, head, "blob") + line(tag, head, "tag"))
|
||||
assert [r.disposition for r in result.updates] == ["missing_target", "unsupported_target", "unsupported_target"]
|
||||
assert result.targets == result.comparisons == ()
|
||||
result = SnapshotAdapter(repo).resolve_updates(line(head, blob))
|
||||
assert result.updates[0].disposition == "unsupported_base"
|
||||
|
||||
|
||||
def test_bad_protocol_never_silently_skips_lines(repo: Path) -> None:
|
||||
from openultrasast.push.snapshot import SnapshotInputError
|
||||
head = commit(repo, "head")
|
||||
for invalid in ("\n", "only three fields\n", line(head[:-1], head), line(head, head, "bad..ref"),
|
||||
line("0" * len(head), head), f"(delete) {head} refs/heads/main {head}\n"):
|
||||
with pytest.raises(SnapshotInputError):
|
||||
SnapshotAdapter(repo).resolve_updates(line(head, head) + invalid)
|
||||
|
||||
|
||||
def test_replacements_and_inherited_git_environment_do_not_change_identity(repo: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
root = commit(repo, "root")
|
||||
original = commit(repo, "original", root)
|
||||
replacement = commit(repo, "replacement")
|
||||
git(repo, "replace", original, replacement)
|
||||
monkeypatch.setenv("GIT_DIR", str(repo / "nonexistent"))
|
||||
monkeypatch.setenv("GIT_OBJECT_DIRECTORY", str(repo / "nonexistent"))
|
||||
result = SnapshotAdapter(repo, comparison_base=root).resolve_updates(line(original, "0" * len(root)))
|
||||
assert result.updates[0].disposition == "ready"
|
||||
assert result.comparisons[0].base_oid == root
|
||||
assert result.comparisons[0].head_oid == original
|
||||
|
||||
|
||||
def test_missing_promisor_object_cannot_fetch_even_when_repo_allows_file(repo: Path, tmp_path: Path) -> None:
|
||||
source = repo / "source"
|
||||
source.mkdir()
|
||||
fmt = git(repo, "rev-parse", "--show-object-format")
|
||||
git(source, "init", "--object-format=" + fmt)
|
||||
git(source, "config", "user.name", "Fixture")
|
||||
git(source, "config", "user.email", "[email protected]")
|
||||
missing = commit(source, "must not fetch")
|
||||
git(source, "update-ref", "refs/heads/main", missing)
|
||||
git(repo, "config", "remote.origin.url", str(source))
|
||||
git(repo, "config", "remote.origin.promisor", "true")
|
||||
git(repo, "config", "remote.origin.partialclonefilter", "blob:none")
|
||||
git(repo, "config", "protocol.file.allow", "always")
|
||||
before = sorted(p.relative_to(repo / ".git/objects") for p in (repo / ".git/objects").rglob("*") if p.is_file())
|
||||
result = SnapshotAdapter(repo).resolve_updates(line(missing, "0" * len(missing)))
|
||||
after = sorted(p.relative_to(repo / ".git/objects") for p in (repo / ".git/objects").rglob("*") if p.is_file())
|
||||
assert result.updates[0].disposition == "missing_target"
|
||||
assert before == after
|
||||
|
||||
|
||||
def test_missing_tag_referent_is_missing_not_unsupported(repo: Path) -> None:
|
||||
head = commit(repo, "head")
|
||||
git(repo, "tag", "-a", "dangling", head, "-m", "dangling")
|
||||
tag = git(repo, "rev-parse", "dangling")
|
||||
(repo / ".git" / "objects" / head[:2] / head[2:]).unlink()
|
||||
result = SnapshotAdapter(repo).resolve_updates(f"refs/tags/dangling {tag} refs/tags/dangling {'0' * len(head)}\n")
|
||||
assert result.updates[0].disposition == "missing_target"
|
||||
|
||||
|
||||
def test_legacy_grafts_cannot_rewrite_comparison_ancestry(repo: Path) -> None:
|
||||
root = commit(repo, "root")
|
||||
head = commit(repo, "head", root)
|
||||
(repo / ".git/info/grafts").write_text(head + "\n")
|
||||
result = SnapshotAdapter(repo, comparison_base=root).resolve_updates(line(head, "0" * len(head)))
|
||||
assert result.comparisons[0].base_oid == root
|
||||
|
||||
|
||||
def test_dirty_non_head_and_duplicate_update_records_are_preserved(repo: Path) -> None:
|
||||
base = commit(repo, "base")
|
||||
pushed = commit(repo, "pushed", base)
|
||||
checked = commit(repo, "checked", base)
|
||||
git(repo, "update-ref", "HEAD", checked)
|
||||
(repo / "file.js").write_text("dirty file")
|
||||
git(repo, "add", "file.js")
|
||||
before_index = (repo / ".git/index").read_bytes()
|
||||
result = SnapshotAdapter(repo).resolve_updates(line(pushed, base) * 2)
|
||||
assert result.targets == (pushed,)
|
||||
assert len(result.updates) == 2
|
||||
assert len(result.comparisons) == 1
|
||||
assert (repo / ".git/index").read_bytes() == before_index
|
||||
assert (repo / "file.js").read_text() == "dirty file"
|
||||
assert git(repo, "rev-parse", "HEAD") == checked
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name", ["unicode\u00a0inside", "unicode-trailing\u00a0", "unicode\u2028separator"])
|
||||
def test_git_valid_unicode_ref_names_are_preserved(repo: Path, name: str) -> None:
|
||||
base = commit(repo, "base")
|
||||
head = commit(repo, "head", base)
|
||||
ref = "refs/heads/" + name
|
||||
git(repo, "check-ref-format", ref)
|
||||
git(repo, "update-ref", ref, head)
|
||||
result = SnapshotAdapter(repo).resolve_updates(line(head, base, name))
|
||||
assert result.updates[0].update.local_ref == ref
|
||||
assert result.updates[0].update.remote_ref == ref
|
||||
assert result.comparisons[0].refs == (ref,)
|
||||
assert result.updates[0].disposition == "ready"
|
||||
Reference in New Issue
Block a user