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.
@@ -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
+ )