git-paoding 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- git_paoding/__init__.py +32 -0
- git_paoding/_agent_plugins/__init__.py +1 -0
- git_paoding/_agent_plugins/git-paoding/.claude-plugin/plugin.json +12 -0
- git_paoding/_agent_plugins/git-paoding/.codex-plugin/plugin.json +22 -0
- git_paoding/_agent_plugins/git-paoding/skills/git-paoding/SKILL.md +206 -0
- git_paoding/agent_install.py +113 -0
- git_paoding/api.py +383 -0
- git_paoding/cli/__init__.py +1 -0
- git_paoding/cli/facade.py +133 -0
- git_paoding/cli/main.py +277 -0
- git_paoding/cli/render.py +205 -0
- git_paoding/core/__init__.py +1 -0
- git_paoding/core/diffatoms.py +208 -0
- git_paoding/core/model.py +292 -0
- git_paoding/core/projection.py +376 -0
- git_paoding/core/publish.py +644 -0
- git_paoding/core/reconcile.py +220 -0
- git_paoding/core/selectors.py +279 -0
- git_paoding/github/__init__.py +1 -0
- git_paoding/github/backend.py +53 -0
- git_paoding/github/gh_cli.py +339 -0
- git_paoding/github/lifecycle.py +123 -0
- git_paoding/github/prbody.py +281 -0
- git_paoding/gitio/__init__.py +49 -0
- git_paoding/gitio/diffparse.py +273 -0
- git_paoding/gitio/plumbing.py +170 -0
- git_paoding/gitio/refs.py +145 -0
- git_paoding/gitio/runner.py +133 -0
- git_paoding/py.typed +1 -0
- git_paoding/store/__init__.py +6 -0
- git_paoding/store/jsonstore.py +142 -0
- git_paoding/store/lock.py +165 -0
- git_paoding-0.1.0.dist-info/METADATA +351 -0
- git_paoding-0.1.0.dist-info/RECORD +36 -0
- git_paoding-0.1.0.dist-info/WHEEL +4 -0
- git_paoding-0.1.0.dist-info/entry_points.txt +3 -0
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
"""Persistent and contract-facing models.
|
|
2
|
+
|
|
3
|
+
This module is the single definition of the JSON persistence and public machine
|
|
4
|
+
contracts. Pydantic therefore owns validation, serialization, and JSON Schema
|
|
5
|
+
generation for all of these types.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from enum import Enum
|
|
11
|
+
from typing import Annotated, Final, Literal
|
|
12
|
+
|
|
13
|
+
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
|
14
|
+
|
|
15
|
+
SCHEMA_VERSION: Final = 1
|
|
16
|
+
CONTRACT_VERSION: Final = 0
|
|
17
|
+
|
|
18
|
+
NonEmptyString = Annotated[str, Field(min_length=1)]
|
|
19
|
+
NonNegativeInt = Annotated[int, Field(ge=0)]
|
|
20
|
+
PositiveInt = Annotated[int, Field(gt=0)]
|
|
21
|
+
SliceId = Annotated[str, Field(pattern=r"^[a-z0-9]+(?:-[a-z0-9]+)*$")]
|
|
22
|
+
SlicePrPrefix = Annotated[
|
|
23
|
+
str,
|
|
24
|
+
Field(min_length=1, max_length=40, pattern=r"^[A-Za-z0-9._/-]+$"),
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class PaodingError(RuntimeError):
|
|
29
|
+
"""Base class for errors callers are expected to present to a user."""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class SessionError(PaodingError):
|
|
33
|
+
"""Base class for invalid or unavailable session state."""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class SessionNotFoundError(SessionError):
|
|
37
|
+
"""Raised when no session exists for a canonical branch."""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class SessionAlreadyExistsError(SessionError):
|
|
41
|
+
"""Raised when initialization would replace an existing session."""
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class SessionValidationError(SessionError):
|
|
45
|
+
"""Raised when persisted session JSON is malformed or internally invalid."""
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class UnsupportedSchemaVersionError(SessionError):
|
|
49
|
+
"""Raised when session data uses a schema this version cannot read."""
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class BaseDriftError(SessionError):
|
|
53
|
+
"""Raised when an operation would implicitly change a session's pinned base."""
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class SessionLockError(PaodingError):
|
|
57
|
+
"""Base class for advisory session-lock failures."""
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class ConcurrentSessionAccessError(SessionLockError):
|
|
61
|
+
"""Raised when another mutating process currently owns a session lock."""
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class StaleSessionLockError(SessionLockError):
|
|
65
|
+
"""Raised when a stale lock requires an explicit override."""
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class _Model(BaseModel):
|
|
69
|
+
"""Strict base configuration shared by persistent and contract models."""
|
|
70
|
+
|
|
71
|
+
model_config = ConfigDict(extra="forbid", validate_assignment=True)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class SliceStatus(str, Enum):
|
|
75
|
+
"""Lifecycle of a semantic review slice."""
|
|
76
|
+
|
|
77
|
+
ACTIVE = "active"
|
|
78
|
+
ARCHIVED = "archived"
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class AtomKind(str, Enum):
|
|
82
|
+
"""Kinds of Base-to-Final changes represented by an atom."""
|
|
83
|
+
|
|
84
|
+
MODIFY = "modify"
|
|
85
|
+
ADD_FILE = "add-file"
|
|
86
|
+
DELETE_FILE = "delete-file"
|
|
87
|
+
WHOLE_FILE = "whole-file"
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class AtomState(str, Enum):
|
|
91
|
+
"""Current attribution state of an atom."""
|
|
92
|
+
|
|
93
|
+
ASSIGNED = "assigned"
|
|
94
|
+
UNASSIGNED = "unassigned"
|
|
95
|
+
AMBIGUOUS = "ambiguous"
|
|
96
|
+
UPDATED = "updated"
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class PRState(str, Enum):
|
|
100
|
+
"""GitHub pull-request lifecycle state used by the backend seam."""
|
|
101
|
+
|
|
102
|
+
OPEN = "open"
|
|
103
|
+
CLOSED = "closed"
|
|
104
|
+
MERGED = "merged"
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class PublishOutcome(str, Enum):
|
|
108
|
+
"""Effect of publishing one slice."""
|
|
109
|
+
|
|
110
|
+
CREATED = "created"
|
|
111
|
+
REFRESHED = "refreshed"
|
|
112
|
+
NO_OP = "no-op"
|
|
113
|
+
EMPTY = "empty"
|
|
114
|
+
SKIPPED = "skipped"
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
class Slice(_Model):
|
|
118
|
+
"""Stable author-controlled identity for one semantic review slice."""
|
|
119
|
+
|
|
120
|
+
id: SliceId
|
|
121
|
+
title: NonEmptyString
|
|
122
|
+
pr_number: PositiveInt | None = None
|
|
123
|
+
status: SliceStatus = SliceStatus.ACTIVE
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
class Atom(_Model):
|
|
127
|
+
"""One Base-anchored hunk with at most one primary owner."""
|
|
128
|
+
|
|
129
|
+
atom_id: NonEmptyString
|
|
130
|
+
path: NonEmptyString
|
|
131
|
+
kind: AtomKind
|
|
132
|
+
base_start: NonNegativeInt
|
|
133
|
+
base_len: NonNegativeInt
|
|
134
|
+
final_start: NonNegativeInt
|
|
135
|
+
final_len: NonNegativeInt
|
|
136
|
+
gap_seq: NonNegativeInt = 0
|
|
137
|
+
content_hash: NonEmptyString
|
|
138
|
+
owner: SliceId | None = None
|
|
139
|
+
state: AtomState
|
|
140
|
+
preview: str = ""
|
|
141
|
+
|
|
142
|
+
@model_validator(mode="after")
|
|
143
|
+
def validate_owner_matches_state(self) -> Atom:
|
|
144
|
+
"""Keep owner presence consistent with attribution state."""
|
|
145
|
+
|
|
146
|
+
owner_required = self.state in {AtomState.ASSIGNED, AtomState.UPDATED}
|
|
147
|
+
if owner_required and self.owner is None:
|
|
148
|
+
raise ValueError(f"state {self.state.value!r} requires an owner")
|
|
149
|
+
if not owner_required and self.owner is not None:
|
|
150
|
+
raise ValueError(f"state {self.state.value!r} cannot have an owner")
|
|
151
|
+
return self
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
class Session(_Model):
|
|
155
|
+
"""Persistent state for one canonical integration branch."""
|
|
156
|
+
|
|
157
|
+
schema_version: Literal[1] = SCHEMA_VERSION
|
|
158
|
+
canonical_branch: NonEmptyString
|
|
159
|
+
base_ref: NonEmptyString | None = None
|
|
160
|
+
base_oid: NonEmptyString
|
|
161
|
+
slice_pr_prefix: SlicePrPrefix = "slice"
|
|
162
|
+
slices: list[Slice] = Field(default_factory=list)
|
|
163
|
+
atoms: list[Atom] = Field(default_factory=list)
|
|
164
|
+
last_final_oid: NonEmptyString | None = None
|
|
165
|
+
focus_slice: SliceId | None = None
|
|
166
|
+
integration_pr: PositiveInt | None = None
|
|
167
|
+
archived: bool = False
|
|
168
|
+
|
|
169
|
+
@model_validator(mode="after")
|
|
170
|
+
def validate_references(self) -> Session:
|
|
171
|
+
"""Reject ambiguous identities and dangling slice references."""
|
|
172
|
+
|
|
173
|
+
slice_ids = [slice_.id for slice_ in self.slices]
|
|
174
|
+
if len(slice_ids) != len(set(slice_ids)):
|
|
175
|
+
raise ValueError("slice ids must be unique within a session")
|
|
176
|
+
|
|
177
|
+
atom_ids = [atom.atom_id for atom in self.atoms]
|
|
178
|
+
if len(atom_ids) != len(set(atom_ids)):
|
|
179
|
+
raise ValueError("atom ids must be unique within a session")
|
|
180
|
+
|
|
181
|
+
known_slices = set(slice_ids)
|
|
182
|
+
dangling_owners = sorted(
|
|
183
|
+
{atom.owner for atom in self.atoms if atom.owner is not None} - known_slices
|
|
184
|
+
)
|
|
185
|
+
if dangling_owners:
|
|
186
|
+
raise ValueError(f"atom owners reference unknown slices: {', '.join(dangling_owners)}")
|
|
187
|
+
if self.focus_slice is not None and self.focus_slice not in known_slices:
|
|
188
|
+
raise ValueError(f"focus_slice references unknown slice: {self.focus_slice}")
|
|
189
|
+
return self
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
class PRRecord(_Model):
|
|
193
|
+
"""Backend-neutral representation of a GitHub pull request."""
|
|
194
|
+
|
|
195
|
+
number: PositiveInt
|
|
196
|
+
url: NonEmptyString
|
|
197
|
+
title: NonEmptyString
|
|
198
|
+
body: str
|
|
199
|
+
state: PRState
|
|
200
|
+
is_draft: bool
|
|
201
|
+
base_ref: NonEmptyString
|
|
202
|
+
head_ref: NonEmptyString
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
class DiffStat(_Model):
|
|
206
|
+
"""Compact review-size summary for a slice."""
|
|
207
|
+
|
|
208
|
+
files_changed: NonNegativeInt = 0
|
|
209
|
+
additions: NonNegativeInt = 0
|
|
210
|
+
deletions: NonNegativeInt = 0
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
class SessionSummary(_Model):
|
|
214
|
+
"""Status-safe summary of persistent session identity."""
|
|
215
|
+
|
|
216
|
+
canonical_branch: NonEmptyString
|
|
217
|
+
base_ref: NonEmptyString | None = None
|
|
218
|
+
base_oid: NonEmptyString
|
|
219
|
+
slice_pr_prefix: SlicePrPrefix = "slice"
|
|
220
|
+
last_final_oid: NonEmptyString | None = None
|
|
221
|
+
focus_slice: SliceId | None = None
|
|
222
|
+
integration_pr: PositiveInt | None = None
|
|
223
|
+
archived: bool = False
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
class SliceSummary(_Model):
|
|
227
|
+
"""One slice entry in the status contract."""
|
|
228
|
+
|
|
229
|
+
id: SliceId
|
|
230
|
+
title: NonEmptyString
|
|
231
|
+
status: SliceStatus
|
|
232
|
+
pr_number: PositiveInt | None = None
|
|
233
|
+
diffstat: DiffStat = Field(default_factory=DiffStat)
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
class StatusResult(_Model):
|
|
237
|
+
"""Machine contract returned by ``status --json``."""
|
|
238
|
+
|
|
239
|
+
contract_version: Literal[0] = CONTRACT_VERSION
|
|
240
|
+
session: SessionSummary
|
|
241
|
+
slices: list[SliceSummary] = Field(default_factory=list)
|
|
242
|
+
atoms: list[Atom] = Field(default_factory=list)
|
|
243
|
+
unassigned_count: NonNegativeInt = 0
|
|
244
|
+
ambiguous_count: NonNegativeInt = 0
|
|
245
|
+
defaulted_atom_ids: list[NonEmptyString] = Field(default_factory=list)
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
class AssignBatchRequest(_Model):
|
|
249
|
+
"""Machine contract accepted by ``assign --batch``."""
|
|
250
|
+
|
|
251
|
+
contract_version: Literal[0] = CONTRACT_VERSION
|
|
252
|
+
assignments: dict[SliceId, list[NonEmptyString]]
|
|
253
|
+
force: bool = False
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
class AssignmentRecord(_Model):
|
|
257
|
+
"""One atom echoed after an assignment attempt."""
|
|
258
|
+
|
|
259
|
+
atom_id: NonEmptyString
|
|
260
|
+
path: NonEmptyString
|
|
261
|
+
previous_owner: SliceId | None = None
|
|
262
|
+
owner: SliceId | None = None
|
|
263
|
+
preview: str = ""
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
class AssignResult(_Model):
|
|
267
|
+
"""Typed result returned by interactive and batch assignment."""
|
|
268
|
+
|
|
269
|
+
contract_version: Literal[0] = CONTRACT_VERSION
|
|
270
|
+
assigned: list[AssignmentRecord] = Field(default_factory=list)
|
|
271
|
+
skipped: list[AssignmentRecord] = Field(default_factory=list)
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
class PublishSliceResult(_Model):
|
|
275
|
+
"""Outcome for one slice during an idempotent publish."""
|
|
276
|
+
|
|
277
|
+
slice_id: SliceId
|
|
278
|
+
title: NonEmptyString
|
|
279
|
+
outcome: PublishOutcome
|
|
280
|
+
pr_number: PositiveInt | None = None
|
|
281
|
+
url: NonEmptyString | None = None
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
class PublishResult(_Model):
|
|
285
|
+
"""Machine contract returned by ``publish --json``."""
|
|
286
|
+
|
|
287
|
+
contract_version: Literal[0] = CONTRACT_VERSION
|
|
288
|
+
slices: list[PublishSliceResult] = Field(default_factory=list)
|
|
289
|
+
integration_pr: PositiveInt | None = None
|
|
290
|
+
integration_pr_url: NonEmptyString | None = None
|
|
291
|
+
action_needed: bool = False
|
|
292
|
+
status: StatusResult | None = None
|
|
@@ -0,0 +1,376 @@
|
|
|
1
|
+
"""Replay of Base-anchored atoms and construction of projection commits.
|
|
2
|
+
|
|
3
|
+
Both halves are deterministic: replay is a pure function of atom payloads, and
|
|
4
|
+
the synthetic commits fix identity and timestamps so an unchanged canonical
|
|
5
|
+
state reproduces byte-identical SHAs.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from collections.abc import Sequence
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from git_paoding.core.diffatoms import ReplayAtom
|
|
15
|
+
from git_paoding.core.model import AtomKind, PaodingError
|
|
16
|
+
from git_paoding.gitio.plumbing import (
|
|
17
|
+
GitIdentity,
|
|
18
|
+
TreeEntry,
|
|
19
|
+
cat_file,
|
|
20
|
+
commit_committer_date,
|
|
21
|
+
commit_tree,
|
|
22
|
+
hash_object,
|
|
23
|
+
ls_tree,
|
|
24
|
+
mktree,
|
|
25
|
+
rev_parse,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
_PROJECTION_IDENTITY = GitIdentity(
|
|
29
|
+
name="git-paoding",
|
|
30
|
+
email="git-paoding@localhost",
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class ReplayError(PaodingError):
|
|
35
|
+
"""Raised when Base-anchored text atoms cannot be replayed safely."""
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class ProjectionError(PaodingError):
|
|
39
|
+
"""Raised when a requested projection cannot form a valid Git tree."""
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass(frozen=True, slots=True)
|
|
43
|
+
class ProjectionCommits:
|
|
44
|
+
"""Deterministic object IDs forming one slice's generated PR branches."""
|
|
45
|
+
|
|
46
|
+
slice_id: str
|
|
47
|
+
final_oid: str
|
|
48
|
+
base_tree_oid: str
|
|
49
|
+
head_tree_oid: str
|
|
50
|
+
base_commit_oid: str
|
|
51
|
+
head_commit_oid: str
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass(slots=True)
|
|
55
|
+
class _TreeNode:
|
|
56
|
+
entries: dict[str, _TreeNode | TreeEntry] = field(default_factory=dict)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _base_index(replay_atom: ReplayAtom) -> int:
|
|
60
|
+
atom = replay_atom.atom
|
|
61
|
+
return atom.base_start if atom.base_len == 0 else atom.base_start - 1
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _application_key(replay_atom: ReplayAtom) -> tuple[int, int, int]:
|
|
65
|
+
"""Order edits for stable in-place splicing against Base coordinates.
|
|
66
|
+
|
|
67
|
+
Higher Base positions run first. At the same list index, replacements run
|
|
68
|
+
before insertions, and shared-gap insertions run in reverse ``gap_seq`` so
|
|
69
|
+
repeated insertion at one index yields their ascending Final order.
|
|
70
|
+
"""
|
|
71
|
+
|
|
72
|
+
atom = replay_atom.atom
|
|
73
|
+
return (_base_index(replay_atom), int(atom.base_len > 0), atom.gap_seq)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def replay_file(
|
|
77
|
+
base_content: bytes | None,
|
|
78
|
+
replay_atoms: Sequence[ReplayAtom],
|
|
79
|
+
) -> bytes | None:
|
|
80
|
+
"""Replay a selected set of Base-anchored text atoms onto one Base file.
|
|
81
|
+
|
|
82
|
+
``None`` represents a missing file, allowing text add/delete atoms to use
|
|
83
|
+
the same primitive. Whole-file atoms are intentionally rejected here:
|
|
84
|
+
binary data, modes, and symlinks are applied by the tree/blob projection
|
|
85
|
+
layer rather than pretending they are line-oriented edits.
|
|
86
|
+
"""
|
|
87
|
+
|
|
88
|
+
if not replay_atoms:
|
|
89
|
+
return base_content
|
|
90
|
+
|
|
91
|
+
paths = {replay_atom.atom.path for replay_atom in replay_atoms}
|
|
92
|
+
if len(paths) != 1:
|
|
93
|
+
raise ReplayError("replay_file accepts atoms for exactly one path")
|
|
94
|
+
|
|
95
|
+
whole_file_ids = [
|
|
96
|
+
replay_atom.atom.atom_id
|
|
97
|
+
for replay_atom in replay_atoms
|
|
98
|
+
if replay_atom.atom.kind is AtomKind.WHOLE_FILE
|
|
99
|
+
]
|
|
100
|
+
if whole_file_ids:
|
|
101
|
+
joined_ids = ", ".join(whole_file_ids)
|
|
102
|
+
raise ReplayError(f"whole-file atoms require tree/blob replay: {joined_ids}")
|
|
103
|
+
|
|
104
|
+
if base_content is None:
|
|
105
|
+
invalid = [
|
|
106
|
+
replay_atom.atom.atom_id
|
|
107
|
+
for replay_atom in replay_atoms
|
|
108
|
+
if replay_atom.atom.kind is not AtomKind.ADD_FILE
|
|
109
|
+
]
|
|
110
|
+
if invalid:
|
|
111
|
+
raise ReplayError("only add-file atoms can be replayed onto a missing Base file")
|
|
112
|
+
lines: list[bytes] = []
|
|
113
|
+
else:
|
|
114
|
+
lines = base_content.splitlines(keepends=True)
|
|
115
|
+
|
|
116
|
+
replacement_indexes: set[int] = set()
|
|
117
|
+
deletes_file = False
|
|
118
|
+
creates_file = False
|
|
119
|
+
for replay_atom in sorted(replay_atoms, key=_application_key, reverse=True):
|
|
120
|
+
atom = replay_atom.atom
|
|
121
|
+
index = _base_index(replay_atom)
|
|
122
|
+
if index < 0 or index > len(lines):
|
|
123
|
+
raise ReplayError(f"atom {atom.atom_id} has an out-of-range Base anchor")
|
|
124
|
+
|
|
125
|
+
if atom.base_len > 0:
|
|
126
|
+
if index in replacement_indexes:
|
|
127
|
+
raise ReplayError(f"atoms overlap at Base index {index}")
|
|
128
|
+
replacement_indexes.add(index)
|
|
129
|
+
end = index + atom.base_len
|
|
130
|
+
if end > len(lines):
|
|
131
|
+
raise ReplayError(f"atom {atom.atom_id} extends past Base content")
|
|
132
|
+
actual_removed = tuple(lines[index:end])
|
|
133
|
+
if actual_removed != replay_atom.removed_lines:
|
|
134
|
+
raise ReplayError(f"atom {atom.atom_id} does not match Base content")
|
|
135
|
+
lines[index:end] = replay_atom.added_lines
|
|
136
|
+
else:
|
|
137
|
+
if replay_atom.removed_lines:
|
|
138
|
+
raise ReplayError(f"insertion atom {atom.atom_id} unexpectedly removes content")
|
|
139
|
+
lines[index:index] = replay_atom.added_lines
|
|
140
|
+
|
|
141
|
+
deletes_file = deletes_file or atom.kind is AtomKind.DELETE_FILE
|
|
142
|
+
creates_file = creates_file or atom.kind is AtomKind.ADD_FILE
|
|
143
|
+
|
|
144
|
+
if deletes_file:
|
|
145
|
+
if creates_file or lines:
|
|
146
|
+
raise ReplayError("delete-file replay did not produce a missing file")
|
|
147
|
+
return None
|
|
148
|
+
return b"".join(lines)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _load_tree(repo: Path, tree_oid: str) -> _TreeNode:
|
|
152
|
+
node = _TreeNode()
|
|
153
|
+
for entry in ls_tree(repo, tree_oid):
|
|
154
|
+
if entry.object_type == "tree":
|
|
155
|
+
node.entries[entry.path] = _load_tree(repo, entry.oid)
|
|
156
|
+
else:
|
|
157
|
+
node.entries[entry.path] = entry
|
|
158
|
+
return node
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _path_parts(path: str) -> tuple[str, ...]:
|
|
162
|
+
parts = tuple(path.split("/"))
|
|
163
|
+
if not parts or any(not part for part in parts):
|
|
164
|
+
raise ProjectionError(f"invalid Git path in atom: {path!r}")
|
|
165
|
+
return parts
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _lookup_entry(root: _TreeNode, path: str) -> TreeEntry | None:
|
|
169
|
+
node = root
|
|
170
|
+
parts = _path_parts(path)
|
|
171
|
+
for part in parts[:-1]:
|
|
172
|
+
child = node.entries.get(part)
|
|
173
|
+
if child is None:
|
|
174
|
+
return None
|
|
175
|
+
if isinstance(child, TreeEntry):
|
|
176
|
+
# A file-to-directory (or reverse) transition makes the deeper
|
|
177
|
+
# path absent on this side of the comparison.
|
|
178
|
+
return None
|
|
179
|
+
node = child
|
|
180
|
+
value = node.entries.get(parts[-1])
|
|
181
|
+
if isinstance(value, _TreeNode):
|
|
182
|
+
return None
|
|
183
|
+
return value
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _delete_path(root: _TreeNode, path: str) -> None:
|
|
187
|
+
parts = _path_parts(path)
|
|
188
|
+
|
|
189
|
+
def remove(node: _TreeNode, index: int) -> bool:
|
|
190
|
+
part = parts[index]
|
|
191
|
+
if index == len(parts) - 1:
|
|
192
|
+
node.entries.pop(part, None)
|
|
193
|
+
return not node.entries
|
|
194
|
+
child = node.entries.get(part)
|
|
195
|
+
if child is None:
|
|
196
|
+
return not node.entries
|
|
197
|
+
if isinstance(child, TreeEntry):
|
|
198
|
+
# Removing a deeper path beneath a file is already satisfied. A
|
|
199
|
+
# desired replacement will create the needed directory later.
|
|
200
|
+
return not node.entries
|
|
201
|
+
if remove(child, index + 1):
|
|
202
|
+
node.entries.pop(part, None)
|
|
203
|
+
return not node.entries
|
|
204
|
+
|
|
205
|
+
remove(root, 0)
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _set_path(root: _TreeNode, path: str, entry: TreeEntry) -> None:
|
|
209
|
+
node = root
|
|
210
|
+
parts = _path_parts(path)
|
|
211
|
+
for part in parts[:-1]:
|
|
212
|
+
child = node.entries.get(part)
|
|
213
|
+
if child is None:
|
|
214
|
+
child = _TreeNode()
|
|
215
|
+
node.entries[part] = child
|
|
216
|
+
elif isinstance(child, TreeEntry):
|
|
217
|
+
raise ProjectionError(
|
|
218
|
+
f"cannot create {path!r}: its parent component {part!r} is a file"
|
|
219
|
+
)
|
|
220
|
+
node = child
|
|
221
|
+
existing = node.entries.get(parts[-1])
|
|
222
|
+
if isinstance(existing, _TreeNode):
|
|
223
|
+
raise ProjectionError(f"cannot replace tree {path!r} with a file in one projection")
|
|
224
|
+
node.entries[parts[-1]] = TreeEntry(
|
|
225
|
+
mode=entry.mode,
|
|
226
|
+
object_type=entry.object_type,
|
|
227
|
+
oid=entry.oid,
|
|
228
|
+
path=parts[-1],
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _write_tree(repo: Path, node: _TreeNode) -> str:
|
|
233
|
+
entries: list[TreeEntry] = []
|
|
234
|
+
for name, value in node.entries.items():
|
|
235
|
+
if isinstance(value, _TreeNode):
|
|
236
|
+
entries.append(
|
|
237
|
+
TreeEntry(
|
|
238
|
+
mode="040000",
|
|
239
|
+
object_type="tree",
|
|
240
|
+
oid=_write_tree(repo, value),
|
|
241
|
+
path=name,
|
|
242
|
+
)
|
|
243
|
+
)
|
|
244
|
+
else:
|
|
245
|
+
entries.append(value)
|
|
246
|
+
return mktree(repo, entries)
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def _entry_content(repo: Path, entry: TreeEntry | None, *, path: str) -> bytes | None:
|
|
250
|
+
if entry is None:
|
|
251
|
+
return None
|
|
252
|
+
if entry.object_type != "blob":
|
|
253
|
+
raise ProjectionError(f"text atom path {path!r} does not resolve to a blob")
|
|
254
|
+
return cat_file(repo, entry.oid)
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def _synthetic_entry(
|
|
258
|
+
repo: Path,
|
|
259
|
+
*,
|
|
260
|
+
path: str,
|
|
261
|
+
slice_id: str,
|
|
262
|
+
path_atoms: Sequence[ReplayAtom],
|
|
263
|
+
base_entry: TreeEntry | None,
|
|
264
|
+
final_entry: TreeEntry | None,
|
|
265
|
+
) -> TreeEntry | None:
|
|
266
|
+
whole_file_atoms = [item for item in path_atoms if item.atom.kind is AtomKind.WHOLE_FILE]
|
|
267
|
+
if whole_file_atoms:
|
|
268
|
+
if len(path_atoms) != 1 or len(whole_file_atoms) != 1:
|
|
269
|
+
raise ProjectionError(f"whole-file path {path!r} must have exactly one atom")
|
|
270
|
+
# This function is called only for paths touched by the requested
|
|
271
|
+
# slice, so removing that sole whole-file atom restores Base exactly.
|
|
272
|
+
return base_entry
|
|
273
|
+
|
|
274
|
+
non_slice_atoms = tuple(item for item in path_atoms if item.atom.owner != slice_id)
|
|
275
|
+
content = replay_file(
|
|
276
|
+
_entry_content(repo, base_entry, path=path),
|
|
277
|
+
non_slice_atoms,
|
|
278
|
+
)
|
|
279
|
+
if content is None:
|
|
280
|
+
return None
|
|
281
|
+
|
|
282
|
+
mode_source = final_entry or base_entry
|
|
283
|
+
if mode_source is None or mode_source.object_type != "blob":
|
|
284
|
+
raise ProjectionError(f"could not determine blob mode for projected path {path!r}")
|
|
285
|
+
return TreeEntry(
|
|
286
|
+
mode=mode_source.mode,
|
|
287
|
+
object_type="blob",
|
|
288
|
+
oid=hash_object(repo, content),
|
|
289
|
+
path=_path_parts(path)[-1],
|
|
290
|
+
)
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def build_projection(
|
|
294
|
+
repo: Path,
|
|
295
|
+
*,
|
|
296
|
+
base_oid: str,
|
|
297
|
+
final_oid: str,
|
|
298
|
+
slice_id: str,
|
|
299
|
+
replay_atoms: Sequence[ReplayAtom],
|
|
300
|
+
) -> ProjectionCommits:
|
|
301
|
+
"""Build deterministic full-Final-tree projection commits for one slice.
|
|
302
|
+
|
|
303
|
+
The synthetic base starts from the complete Final tree and replaces only
|
|
304
|
+
files containing ``slice_id`` atoms with Base plus all non-slice atoms.
|
|
305
|
+
The generated head always uses the untouched full Final tree. All objects
|
|
306
|
+
are written through Git plumbing; HEAD, the index, and the worktree are not
|
|
307
|
+
consulted or modified.
|
|
308
|
+
"""
|
|
309
|
+
|
|
310
|
+
if not slice_id:
|
|
311
|
+
raise ProjectionError("slice id must not be empty")
|
|
312
|
+
|
|
313
|
+
base_tree_oid = rev_parse(repo, f"{base_oid}^{{tree}}")
|
|
314
|
+
final_tree_oid = rev_parse(repo, f"{final_oid}^{{tree}}")
|
|
315
|
+
base_root = _load_tree(repo, base_tree_oid)
|
|
316
|
+
synthetic_root = _load_tree(repo, final_tree_oid)
|
|
317
|
+
|
|
318
|
+
atoms_by_path: dict[str, list[ReplayAtom]] = {}
|
|
319
|
+
for replay_atom in replay_atoms:
|
|
320
|
+
atoms_by_path.setdefault(replay_atom.atom.path, []).append(replay_atom)
|
|
321
|
+
|
|
322
|
+
desired_entries: dict[str, TreeEntry | None] = {}
|
|
323
|
+
for path, path_atoms in atoms_by_path.items():
|
|
324
|
+
if not any(item.atom.owner == slice_id for item in path_atoms):
|
|
325
|
+
continue
|
|
326
|
+
desired_entries[path] = _synthetic_entry(
|
|
327
|
+
repo,
|
|
328
|
+
path=path,
|
|
329
|
+
slice_id=slice_id,
|
|
330
|
+
path_atoms=path_atoms,
|
|
331
|
+
base_entry=_lookup_entry(base_root, path),
|
|
332
|
+
final_entry=_lookup_entry(synthetic_root, path),
|
|
333
|
+
)
|
|
334
|
+
|
|
335
|
+
# Clear every touched path before materializing replacements, so a path
|
|
336
|
+
# can swap between file and directory without colliding with its old entry.
|
|
337
|
+
for path in sorted(desired_entries, key=lambda value: value.count("/"), reverse=True):
|
|
338
|
+
_delete_path(synthetic_root, path)
|
|
339
|
+
for path in sorted(desired_entries, key=lambda value: value.count("/")):
|
|
340
|
+
entry = desired_entries[path]
|
|
341
|
+
if entry is not None:
|
|
342
|
+
_set_path(synthetic_root, path, entry)
|
|
343
|
+
|
|
344
|
+
synthetic_tree_oid = _write_tree(repo, synthetic_root)
|
|
345
|
+
final_date = commit_committer_date(repo, final_oid)
|
|
346
|
+
identity = GitIdentity(
|
|
347
|
+
name=_PROJECTION_IDENTITY.name,
|
|
348
|
+
email=_PROJECTION_IDENTITY.email,
|
|
349
|
+
date=final_date,
|
|
350
|
+
)
|
|
351
|
+
base_message = f"git-paoding projection base\nslice: {slice_id}\nfinal: {final_oid}\n"
|
|
352
|
+
base_commit_oid = commit_tree(
|
|
353
|
+
repo,
|
|
354
|
+
synthetic_tree_oid,
|
|
355
|
+
base_message,
|
|
356
|
+
parents=(base_oid,),
|
|
357
|
+
author=identity,
|
|
358
|
+
committer=identity,
|
|
359
|
+
)
|
|
360
|
+
head_message = f"git-paoding projection head\nslice: {slice_id}\nfinal: {final_oid}\n"
|
|
361
|
+
head_commit_oid = commit_tree(
|
|
362
|
+
repo,
|
|
363
|
+
final_tree_oid,
|
|
364
|
+
head_message,
|
|
365
|
+
parents=(base_commit_oid,),
|
|
366
|
+
author=identity,
|
|
367
|
+
committer=identity,
|
|
368
|
+
)
|
|
369
|
+
return ProjectionCommits(
|
|
370
|
+
slice_id=slice_id,
|
|
371
|
+
final_oid=final_oid,
|
|
372
|
+
base_tree_oid=synthetic_tree_oid,
|
|
373
|
+
head_tree_oid=final_tree_oid,
|
|
374
|
+
base_commit_oid=base_commit_oid,
|
|
375
|
+
head_commit_oid=head_commit_oid,
|
|
376
|
+
)
|