Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
156 changes: 155 additions & 1 deletion src/git_paoding/core/diffatoms.py
Original file line number Diff line number Diff line change
@@ -1 +1,155 @@
"""Diff atom construction."""
"""Construct persistent atom metadata and ephemeral replay payloads."""

from __future__ import annotations

import hashlib
from collections import defaultdict
from collections.abc import Iterable, Sequence
from dataclasses import dataclass

from git_paoding.core.model import Atom, AtomKind, AtomState
from git_paoding.gitio.diffparse import RawDiffHunk

_PREVIEW_LINE_LIMIT = 3


@dataclass(frozen=True, slots=True)
class ReplayAtom:
"""An atom paired with the full text payload needed for replay.

``Atom`` remains the compact persistent and contract-facing metadata type.
A ``ReplayAtom`` is deliberately ephemeral: callers reconstruct it from the
authoritative Base-to-Final diff whenever they need to replay content.
"""

atom: Atom
removed_lines: tuple[bytes, ...]
added_lines: tuple[bytes, ...]


def _line_bytes(line: str) -> bytes:
return line.encode("utf-8", errors="surrogateescape")


def _hash_fields(fields: Iterable[bytes]) -> str:
"""Hash length-delimited fields without concatenation ambiguities."""

digest = hashlib.sha256()
for field in fields:
digest.update(len(field).to_bytes(8, byteorder="big"))
digest.update(field)
return digest.hexdigest()


def _content_hash(hunk: RawDiffHunk) -> str:
fields = [b"removed"]
fields.extend(_line_bytes(line) for line in hunk.removed_lines)
fields.append(b"added")
fields.extend(_line_bytes(line) for line in hunk.added_lines)
return _hash_fields(fields)


def _atom_id_digest(
*,
path: str,
base_start: int,
base_len: int,
gap_seq: int,
content_hash: str,
) -> str:
return _hash_fields(
(
path.encode("utf-8", errors="surrogateescape"),
str(base_start).encode("ascii"),
str(base_len).encode("ascii"),
str(gap_seq).encode("ascii"),
content_hash.encode("ascii"),
)
)


def _kind(hunk: RawDiffHunk) -> AtomKind:
if hunk.is_binary or hunk.is_mode_change or hunk.is_symlink:
return AtomKind.WHOLE_FILE
if hunk.is_add_file:
return AtomKind.ADD_FILE
if hunk.is_delete_file:
return AtomKind.DELETE_FILE
return AtomKind.MODIFY


def _preview(hunk: RawDiffHunk) -> str:
def safe_line(prefix: str, line: str) -> str:
raw_line = _line_bytes(line)
return prefix + raw_line.decode("utf-8", errors="replace")

changed_lines = [safe_line("-", line) for line in hunk.removed_lines]
changed_lines.extend(safe_line("+", line) for line in hunk.added_lines)
preview = "".join(changed_lines[:_PREVIEW_LINE_LIMIT])
if len(changed_lines) > _PREVIEW_LINE_LIMIT:
if preview and not preview.endswith("\n"):
preview += "\n"
preview += "…"
return preview


def atomize_hunks(hunks: Sequence[RawDiffHunk]) -> tuple[ReplayAtom, ...]:
"""Convert raw hunks to atoms plus non-persistent text replay payloads.

Pure insertions sharing a Base gap receive monotonically increasing
``gap_seq`` values in their Final/diff order. Atom IDs use A7's first-eight
hash form and receive deterministic ``-N`` suffixes on collisions.
"""

gap_counts: dict[tuple[str, int], int] = defaultdict(int)
id_counts: dict[str, int] = defaultdict(int)
replay_atoms: list[ReplayAtom] = []

for hunk in hunks:
gap_seq = 0
if hunk.base_len == 0:
gap_key = (hunk.path, hunk.base_start)
gap_seq = gap_counts[gap_key]
gap_counts[gap_key] += 1

content_hash = _content_hash(hunk)
short_id = _atom_id_digest(
path=hunk.path,
base_start=hunk.base_start,
base_len=hunk.base_len,
gap_seq=gap_seq,
content_hash=content_hash,
)[:8]
id_counts[short_id] += 1
collision_number = id_counts[short_id]
atom_id = short_id if collision_number == 1 else f"{short_id}-{collision_number}"

atom = Atom(
atom_id=atom_id,
path=hunk.path,
kind=_kind(hunk),
base_start=hunk.base_start,
base_len=hunk.base_len,
final_start=hunk.final_start,
final_len=hunk.final_len,
gap_seq=gap_seq,
content_hash=content_hash,
owner=None,
state=AtomState.UNASSIGNED,
preview=_preview(hunk),
)
replay_atoms.append(
ReplayAtom(
atom=atom,
removed_lines=tuple(_line_bytes(line) for line in hunk.removed_lines),
added_lines=tuple(_line_bytes(line) for line in hunk.added_lines),
)
)

return tuple(replay_atoms)


def build_atoms(hunks: Sequence[RawDiffHunk]) -> tuple[Atom, ...]:
"""Convert raw hunks to compact persistent atoms."""

return tuple(replay_atom.atom for replay_atom in atomize_hunks(hunks))
105 changes: 104 additions & 1 deletion src/git_paoding/core/projection.py
Original file line number Diff line number Diff line change
@@ -1 +1,104 @@
"""Slice projection construction."""
"""Pure replay primitives used by slice projection construction."""

from __future__ import annotations

from collections.abc import Sequence

from git_paoding.core.diffatoms import ReplayAtom
from git_paoding.core.model import AtomKind, PaodingError


class ReplayError(PaodingError):
"""Raised when Base-anchored text atoms cannot be replayed safely."""


def _base_index(replay_atom: ReplayAtom) -> int:
atom = replay_atom.atom
return atom.base_start if atom.base_len == 0 else atom.base_start - 1


def _application_key(replay_atom: ReplayAtom) -> tuple[int, int, int]:
"""Order edits for stable in-place splicing against Base coordinates.

Higher Base positions run first. At the same list index, replacements run
before insertions, and shared-gap insertions run in reverse ``gap_seq`` so
repeated insertion at one index yields their ascending Final order.
"""

atom = replay_atom.atom
return (_base_index(replay_atom), int(atom.base_len > 0), atom.gap_seq)


def replay_file(
base_content: bytes | None,
replay_atoms: Sequence[ReplayAtom],
) -> bytes | None:
"""Replay a selected set of Base-anchored text atoms onto one Base file.

``None`` represents a missing file, allowing text add/delete atoms to use
the same primitive. Whole-file atoms are intentionally rejected here:
binary data, modes, and symlinks are applied by the tree/blob projection
layer rather than pretending they are line-oriented edits.
"""

if not replay_atoms:
return base_content

paths = {replay_atom.atom.path for replay_atom in replay_atoms}
if len(paths) != 1:
raise ReplayError("replay_file accepts atoms for exactly one path")

whole_file_ids = [
replay_atom.atom.atom_id
for replay_atom in replay_atoms
if replay_atom.atom.kind is AtomKind.WHOLE_FILE
]
if whole_file_ids:
joined_ids = ", ".join(whole_file_ids)
raise ReplayError(f"whole-file atoms require tree/blob replay: {joined_ids}")

if base_content is None:
invalid = [
replay_atom.atom.atom_id
for replay_atom in replay_atoms
if replay_atom.atom.kind is not AtomKind.ADD_FILE
]
if invalid:
raise ReplayError("only add-file atoms can be replayed onto a missing Base file")
lines: list[bytes] = []
else:
lines = base_content.splitlines(keepends=True)

replacement_indexes: set[int] = set()
deletes_file = False
creates_file = False
for replay_atom in sorted(replay_atoms, key=_application_key, reverse=True):
atom = replay_atom.atom
index = _base_index(replay_atom)
if index < 0 or index > len(lines):
raise ReplayError(f"atom {atom.atom_id} has an out-of-range Base anchor")

if atom.base_len > 0:
if index in replacement_indexes:
raise ReplayError(f"atoms overlap at Base index {index}")
replacement_indexes.add(index)
end = index + atom.base_len
if end > len(lines):
raise ReplayError(f"atom {atom.atom_id} extends past Base content")
actual_removed = tuple(lines[index:end])
if actual_removed != replay_atom.removed_lines:
raise ReplayError(f"atom {atom.atom_id} does not match Base content")
lines[index:end] = replay_atom.added_lines
else:
if replay_atom.removed_lines:
raise ReplayError(f"insertion atom {atom.atom_id} unexpectedly removes content")
lines[index:index] = replay_atom.added_lines

deletes_file = deletes_file or atom.kind is AtomKind.DELETE_FILE
creates_file = creates_file or atom.kind is AtomKind.ADD_FILE

if deletes_file:
if creates_file or lines:
raise ReplayError("delete-file replay did not produce a missing file")
return None
return b"".join(lines)
43 changes: 43 additions & 0 deletions tests/integration/test_diffatoms_real_git.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
"""Atom construction checked against real Git diff output."""

from __future__ import annotations

import re

import pytest

from conftest import RepoFile, ScratchRepoFactory
from git_paoding.core.diffatoms import build_atoms
from git_paoding.core.model import AtomKind
from git_paoding.gitio.diffparse import diff_trees


@pytest.mark.integration
def test_real_diff_confirms_rename_whole_file_and_atom_id_assumptions(
scratch_repo_factory: ScratchRepoFactory,
) -> None:
repo = scratch_repo_factory(
{
"old-name.txt": "moved\n",
"binary.bin": b"\x00before",
"script.sh": "echo hi\n",
"link": RepoFile("old-target", symlink=True),
},
{
"new-name.txt": "moved\n",
"binary.bin": b"\x00after",
"script.sh": RepoFile("echo hi\n", executable=True),
"link": RepoFile("new-target", symlink=True),
},
)

atoms = build_atoms(diff_trees(repo.path, repo.base_oid, repo.final_oid))
by_path = {atom.path: atom for atom in atoms}

assert by_path["old-name.txt"].kind is AtomKind.DELETE_FILE
assert by_path["new-name.txt"].kind is AtomKind.ADD_FILE
assert by_path["binary.bin"].kind is AtomKind.WHOLE_FILE
assert by_path["script.sh"].kind is AtomKind.WHOLE_FILE
assert by_path["link"].kind is AtomKind.WHOLE_FILE
assert all(re.fullmatch(r"[0-9a-f]{8}(?:-\d+)?", atom.atom_id) for atom in atoms)
assert len({atom.atom_id for atom in atoms}) == len(atoms)
Loading
Loading