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,281 @@
1
+ """Safe rendering and replacement of machine-managed PR body regions.
2
+
3
+ Slice diffstats are supplied by callers from the reconciled atom set. Keeping
4
+ that calculation outside this module avoids a second, Git-derived source of
5
+ truth and preserves the boundary that only :mod:`git_paoding.gitio` invokes
6
+ Git.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import re
12
+ from collections.abc import Sequence
13
+ from dataclasses import dataclass
14
+
15
+ from git_paoding.core.model import DiffStat, SliceId
16
+
17
+ MACHINE_REGION_START = "<!-- paoding-managed:start -->"
18
+ MACHINE_REGION_END = "<!-- paoding-managed:end -->"
19
+ LIFECYCLE_REGION_START = "<!-- paoding-lifecycle:start -->"
20
+ LIFECYCLE_REGION_END = "<!-- paoding-lifecycle:end -->"
21
+ SLICE_MARKER_PREFIX = "<!-- paoding-slice-id: "
22
+ INTEGRATION_MARKER = "<!-- paoding-integration-pr -->"
23
+
24
+ DO_NOT_MERGE_BANNER = (
25
+ "> [!CAUTION]\n"
26
+ "> **DO NOT MERGE — review projection only.** Final CI, approval, and merge belong to "
27
+ "the integration PR."
28
+ )
29
+
30
+
31
+ @dataclass(frozen=True, slots=True)
32
+ class RelatedSliceLink:
33
+ """A published slice that overlaps the current slice by changed path."""
34
+
35
+ number: int
36
+ title: str
37
+ url: str
38
+ shared_paths: tuple[str, ...]
39
+
40
+
41
+ @dataclass(frozen=True, slots=True)
42
+ class IntegrationSliceLink:
43
+ """One row in the integration PR's machine-managed slice index."""
44
+
45
+ slice_id: str
46
+ title: str
47
+ number: int | None
48
+ url: str | None
49
+
50
+
51
+ def slice_marker(slice_id: SliceId | str) -> str:
52
+ """Return the stable, machine-readable PR identity marker for a slice."""
53
+
54
+ return f"{SLICE_MARKER_PREFIX}{slice_id} -->"
55
+
56
+
57
+ def _escape_link_text(value: str) -> str:
58
+ return value.replace("\\", "\\\\").replace("[", "\\[").replace("]", "\\]")
59
+
60
+
61
+ def _inline_code(value: str) -> str:
62
+ fence = "`" if "`" not in value else "``"
63
+ return f"{fence}{value}{fence}"
64
+
65
+
66
+ def _pr_number_from_url(url: str) -> int | None:
67
+ match = re.search(r"/(?:pull|pulls)/(\d+)/?$", url)
68
+ return int(match.group(1)) if match is not None else None
69
+
70
+
71
+ def _pr_link(*, number: int | None, title: str, url: str) -> str:
72
+ prefix = f"#{number} " if number is not None else ""
73
+ return f"[{prefix}{_escape_link_text(title)}]({url})"
74
+
75
+
76
+ def render_diffstat(diffstat: DiffStat) -> str:
77
+ """Render an atoms-derived review-size summary."""
78
+
79
+ noun = "file" if diffstat.files_changed == 1 else "files"
80
+ return (
81
+ f"**Diffstat:** {diffstat.files_changed} {noun} changed, "
82
+ f"+{diffstat.additions} −{diffstat.deletions}"
83
+ )
84
+
85
+
86
+ def render_slice_machine_content(
87
+ *,
88
+ slice_id: SliceId | str,
89
+ integration_pr_url: str,
90
+ diffstat: DiffStat | None = None,
91
+ related_slices: Sequence[RelatedSliceLink] = (),
92
+ currently_empty: bool = False,
93
+ ) -> str:
94
+ """Render the machine-owned metadata for a slice review pull request.
95
+
96
+ Callers may omit ``diffstat`` and ``related_slices`` to render only the
97
+ core metadata. Publication paths provide both from the same reconciled
98
+ atom set used to construct the projection.
99
+ """
100
+
101
+ integration_number = _pr_number_from_url(integration_pr_url)
102
+ parts = [
103
+ DO_NOT_MERGE_BANNER,
104
+ "Integration PR: "
105
+ + _pr_link(
106
+ number=integration_number,
107
+ title="integration change",
108
+ url=integration_pr_url,
109
+ ),
110
+ ]
111
+ if diffstat is not None:
112
+ parts.append(render_diffstat(diffstat))
113
+ if currently_empty:
114
+ parts.append("_This slice is currently empty._")
115
+ if related_slices:
116
+ related_lines = ["### Related slices sharing changed files"]
117
+ for related in related_slices:
118
+ paths = ", ".join(_inline_code(path) for path in related.shared_paths)
119
+ related_lines.append(
120
+ f"- {_pr_link(number=related.number, title=related.title, url=related.url)} — {paths}"
121
+ )
122
+ parts.append("\n".join(related_lines))
123
+ parts.append(slice_marker(slice_id))
124
+ return "\n\n".join(parts)
125
+
126
+
127
+ def render_integration_machine_content(
128
+ slices: Sequence[IntegrationSliceLink],
129
+ ) -> str:
130
+ """Render the integration PR's machine-owned slice index."""
131
+
132
+ lines = ["## Review slices"]
133
+ if not slices:
134
+ lines.append("_No active review slices._")
135
+ for slice_ in slices:
136
+ if slice_.url is None:
137
+ lines.append(f"- `{slice_.slice_id}` — {slice_.title} _(currently empty)_")
138
+ else:
139
+ lines.append(
140
+ f"- {_pr_link(number=slice_.number, title=slice_.title, url=slice_.url)} "
141
+ f"(`{slice_.slice_id}`)"
142
+ )
143
+ lines.extend(("", INTEGRATION_MARKER))
144
+ return "\n".join(lines)
145
+
146
+
147
+ def _region(content: str, *, start: str, end: str) -> str:
148
+ return f"{start}\n{content}\n{end}"
149
+
150
+
151
+ def machine_region(content: str) -> str:
152
+ """Wrap managed content in the stable HTML-comment delimiters."""
153
+
154
+ return _region(content, start=MACHINE_REGION_START, end=MACHINE_REGION_END)
155
+
156
+
157
+ def _rewrite_region(body: str, content: str, *, start_marker: str, end_marker: str) -> str:
158
+ """Replace the last complete named region, or append a healed region."""
159
+
160
+ start = body.rfind(start_marker)
161
+ end = body.find(end_marker, start + len(start_marker)) if start >= 0 else -1
162
+ replacement = _region(content, start=start_marker, end=end_marker)
163
+ if start >= 0 and end >= 0:
164
+ end += len(end_marker)
165
+ return body[:start] + replacement + body[end:]
166
+
167
+ if not body:
168
+ return replacement
169
+ separator = "\n" if body.endswith("\n") else "\n\n"
170
+ return body + separator + replacement
171
+
172
+
173
+ def rewrite_machine_region(body: str, content: str) -> str:
174
+ """Rewrite only a complete managed region, or append one if it is missing.
175
+
176
+ The last start delimiter is used so a previously dangling delimiter cannot
177
+ capture human prose after a healed region is appended. Every byte outside
178
+ the selected delimiter pair is copied unchanged.
179
+ """
180
+
181
+ return _rewrite_region(
182
+ body,
183
+ content,
184
+ start_marker=MACHINE_REGION_START,
185
+ end_marker=MACHINE_REGION_END,
186
+ )
187
+
188
+
189
+ def rewrite_slice_body(
190
+ body: str,
191
+ *,
192
+ slice_id: SliceId | str,
193
+ integration_pr_url: str,
194
+ diffstat: DiffStat | None = None,
195
+ related_slices: Sequence[RelatedSliceLink] = (),
196
+ currently_empty: bool = False,
197
+ ) -> str:
198
+ """Replace machine-owned slice metadata while preserving author narrative."""
199
+
200
+ return rewrite_machine_region(
201
+ body,
202
+ render_slice_machine_content(
203
+ slice_id=slice_id,
204
+ integration_pr_url=integration_pr_url,
205
+ diffstat=diffstat,
206
+ related_slices=related_slices,
207
+ currently_empty=currently_empty,
208
+ ),
209
+ )
210
+
211
+
212
+ def rewrite_integration_body(
213
+ body: str,
214
+ *,
215
+ slices: Sequence[IntegrationSliceLink],
216
+ ) -> str:
217
+ """Refresh only the integration PR's machine-owned slice index."""
218
+
219
+ return rewrite_machine_region(body, render_integration_machine_content(slices))
220
+
221
+
222
+ def render_removed_slice_note(slice_id: SliceId | str) -> str:
223
+ """Render the durable note left when a slice is removed."""
224
+
225
+ return (
226
+ "> [!NOTE]\n"
227
+ f"> Review slice `{slice_id}` was removed from the active decomposition and closed "
228
+ "without merging. Its discussion is retained for history."
229
+ )
230
+
231
+
232
+ def render_archived_slice_note(
233
+ *,
234
+ integration_pr_number: int,
235
+ integration_pr_url: str,
236
+ merged_commit: str,
237
+ merged_commit_url: str,
238
+ ) -> str:
239
+ """Render the final note left after the integration change is merged."""
240
+
241
+ return (
242
+ "> [!NOTE]\n"
243
+ "> Archived after the integration change merged in "
244
+ f"[#{integration_pr_number}]({integration_pr_url}) at "
245
+ f"[{merged_commit[:12]}]({merged_commit_url}). This projection was closed without "
246
+ "merging and remains available as review history."
247
+ )
248
+
249
+
250
+ def rewrite_removed_slice_body(body: str, *, slice_id: SliceId | str) -> str:
251
+ """Append or refresh the machine-owned removal note."""
252
+
253
+ return _rewrite_region(
254
+ body,
255
+ render_removed_slice_note(slice_id),
256
+ start_marker=LIFECYCLE_REGION_START,
257
+ end_marker=LIFECYCLE_REGION_END,
258
+ )
259
+
260
+
261
+ def rewrite_archived_slice_body(
262
+ body: str,
263
+ *,
264
+ integration_pr_number: int,
265
+ integration_pr_url: str,
266
+ merged_commit: str,
267
+ merged_commit_url: str,
268
+ ) -> str:
269
+ """Append or refresh the machine-owned archive note."""
270
+
271
+ return _rewrite_region(
272
+ body,
273
+ render_archived_slice_note(
274
+ integration_pr_number=integration_pr_number,
275
+ integration_pr_url=integration_pr_url,
276
+ merged_commit=merged_commit,
277
+ merged_commit_url=merged_commit_url,
278
+ ),
279
+ start_marker=LIFECYCLE_REGION_START,
280
+ end_marker=LIFECYCLE_REGION_END,
281
+ )
@@ -0,0 +1,49 @@
1
+ """Git plumbing package."""
2
+
3
+ from git_paoding.gitio.diffparse import RawDiffHunk, diff_trees, parse_diff
4
+ from git_paoding.gitio.plumbing import (
5
+ GitIdentity,
6
+ RemoteRef,
7
+ TreeEntry,
8
+ cat_file,
9
+ commit_committer_date,
10
+ commit_tree,
11
+ hash_object,
12
+ ls_remote,
13
+ ls_tree,
14
+ mktree,
15
+ rev_parse,
16
+ update_ref,
17
+ )
18
+ from git_paoding.gitio.runner import (
19
+ GitCommandError,
20
+ GitError,
21
+ GitFailureKind,
22
+ GitResult,
23
+ GitUnavailableError,
24
+ run_git,
25
+ )
26
+
27
+ __all__ = [
28
+ "GitCommandError",
29
+ "GitError",
30
+ "GitFailureKind",
31
+ "GitIdentity",
32
+ "GitResult",
33
+ "GitUnavailableError",
34
+ "RawDiffHunk",
35
+ "RemoteRef",
36
+ "TreeEntry",
37
+ "cat_file",
38
+ "commit_committer_date",
39
+ "commit_tree",
40
+ "diff_trees",
41
+ "hash_object",
42
+ "ls_remote",
43
+ "ls_tree",
44
+ "mktree",
45
+ "parse_diff",
46
+ "rev_parse",
47
+ "run_git",
48
+ "update_ref",
49
+ ]
@@ -0,0 +1,273 @@
1
+ """Parse zero-context Git diffs into raw, Base-anchored hunk records."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import ast
6
+ import re
7
+ from dataclasses import dataclass, field, replace
8
+ from pathlib import Path
9
+
10
+ from git_paoding.gitio.runner import run_git
11
+
12
+ _DIFF_HEADER = re.compile(r'^diff --git (?P<base>"(?:\\.|[^"])*"|\S+) (?P<final>.+)$')
13
+ _HUNK_HEADER = re.compile(
14
+ r"^@@ -(?P<base_start>\d+)(?:,(?P<base_len>\d+))? "
15
+ r"\+(?P<final_start>\d+)(?:,(?P<final_len>\d+))? @@"
16
+ )
17
+
18
+
19
+ @dataclass(frozen=True, slots=True)
20
+ class RawDiffHunk:
21
+ """One parsed hunk, or one sentinel record for a non-text file change."""
22
+
23
+ path: str
24
+ base_start: int
25
+ base_len: int
26
+ final_start: int
27
+ final_len: int
28
+ removed_lines: tuple[str, ...]
29
+ added_lines: tuple[str, ...]
30
+ is_add_file: bool = False
31
+ is_delete_file: bool = False
32
+ is_binary: bool = False
33
+ is_mode_change: bool = False
34
+ is_symlink: bool = False
35
+ no_newline_at_eof: bool = False
36
+ base_oid: str | None = None
37
+ final_oid: str | None = None
38
+ base_mode: str | None = None
39
+ final_mode: str | None = None
40
+
41
+
42
+ @dataclass(frozen=True, slots=True)
43
+ class _RawFileChange:
44
+ """Object-database identity for one changed path."""
45
+
46
+ path: str
47
+ base_oid: str | None
48
+ final_oid: str | None
49
+ base_mode: str | None
50
+ final_mode: str | None
51
+
52
+
53
+ @dataclass(slots=True)
54
+ class _MutableHunk:
55
+ base_start: int
56
+ base_len: int
57
+ final_start: int
58
+ final_len: int
59
+ removed_lines: list[str] = field(default_factory=list)
60
+ added_lines: list[str] = field(default_factory=list)
61
+ no_newline_at_eof: bool = False
62
+ last_line_kind: str | None = None
63
+
64
+
65
+ @dataclass(slots=True)
66
+ class _FileDiff:
67
+ path: str
68
+ hunks: list[_MutableHunk] = field(default_factory=list)
69
+ is_add_file: bool = False
70
+ is_delete_file: bool = False
71
+ is_binary: bool = False
72
+ is_mode_change: bool = False
73
+ is_symlink: bool = False
74
+
75
+
76
+ def _decode_header_path(value: str) -> str:
77
+ if value.startswith('"'):
78
+ decoded = ast.literal_eval(value)
79
+ if not isinstance(decoded, str):
80
+ raise ValueError(f"Invalid quoted Git path: {value}")
81
+ return decoded
82
+ return value
83
+
84
+
85
+ def _strip_prefix(path: str) -> str:
86
+ if path.startswith(("a/", "b/")):
87
+ return path[2:]
88
+ return path
89
+
90
+
91
+ def _finalize_file(file_diff: _FileDiff | None, records: list[RawDiffHunk]) -> None:
92
+ if file_diff is None:
93
+ return
94
+ hunks = file_diff.hunks or [_MutableHunk(0, 0, 0, 0)]
95
+ for hunk in hunks:
96
+ records.append(
97
+ RawDiffHunk(
98
+ path=file_diff.path,
99
+ base_start=hunk.base_start,
100
+ base_len=hunk.base_len,
101
+ final_start=hunk.final_start,
102
+ final_len=hunk.final_len,
103
+ removed_lines=tuple(hunk.removed_lines),
104
+ added_lines=tuple(hunk.added_lines),
105
+ is_add_file=file_diff.is_add_file,
106
+ is_delete_file=file_diff.is_delete_file,
107
+ is_binary=file_diff.is_binary,
108
+ is_mode_change=file_diff.is_mode_change,
109
+ is_symlink=file_diff.is_symlink,
110
+ no_newline_at_eof=hunk.no_newline_at_eof,
111
+ )
112
+ )
113
+
114
+
115
+ def parse_diff(diff: bytes | str) -> tuple[RawDiffHunk, ...]:
116
+ """Parse output from ``git diff -U0 --no-renames``."""
117
+
118
+ text = diff.decode("utf-8", errors="surrogateescape") if isinstance(diff, bytes) else diff
119
+ records: list[RawDiffHunk] = []
120
+ current_file: _FileDiff | None = None
121
+ current_hunk: _MutableHunk | None = None
122
+
123
+ for line_with_end in text.splitlines(keepends=True):
124
+ line = line_with_end.removesuffix("\n")
125
+ header_match = _DIFF_HEADER.match(line)
126
+ if header_match is not None:
127
+ _finalize_file(current_file, records)
128
+ base_path = _decode_header_path(header_match.group("base"))
129
+ final_path = _decode_header_path(header_match.group("final"))
130
+ path = _strip_prefix(final_path if final_path != "/dev/null" else base_path)
131
+ current_file = _FileDiff(path=path)
132
+ current_hunk = None
133
+ continue
134
+ if current_file is None:
135
+ continue
136
+
137
+ if line.startswith("new file mode "):
138
+ current_file.is_add_file = True
139
+ current_file.is_symlink = line.endswith(" 120000")
140
+ continue
141
+ if line.startswith("deleted file mode "):
142
+ current_file.is_delete_file = True
143
+ current_file.is_symlink = line.endswith(" 120000")
144
+ continue
145
+ if line.startswith("old mode ") or line.startswith("new mode "):
146
+ current_file.is_mode_change = True
147
+ if line.endswith(" 120000"):
148
+ current_file.is_symlink = True
149
+ continue
150
+ if line.startswith("index ") and line.endswith(" 120000"):
151
+ current_file.is_symlink = True
152
+ continue
153
+ if line.startswith("Binary files ") or line == "GIT binary patch":
154
+ current_file.is_binary = True
155
+ continue
156
+
157
+ hunk_match = _HUNK_HEADER.match(line)
158
+ if hunk_match is not None:
159
+ current_hunk = _MutableHunk(
160
+ base_start=int(hunk_match.group("base_start")),
161
+ base_len=int(hunk_match.group("base_len") or "1"),
162
+ final_start=int(hunk_match.group("final_start")),
163
+ final_len=int(hunk_match.group("final_len") or "1"),
164
+ )
165
+ current_file.hunks.append(current_hunk)
166
+ continue
167
+ if current_hunk is None:
168
+ continue
169
+
170
+ if line.startswith("-"):
171
+ current_hunk.removed_lines.append(line_with_end[1:])
172
+ current_hunk.last_line_kind = "removed"
173
+ elif line.startswith("+"):
174
+ current_hunk.added_lines.append(line_with_end[1:])
175
+ current_hunk.last_line_kind = "added"
176
+ elif line == r"":
177
+ current_hunk.no_newline_at_eof = True
178
+ target = (
179
+ current_hunk.removed_lines
180
+ if current_hunk.last_line_kind == "removed"
181
+ else current_hunk.added_lines
182
+ )
183
+ if target and target[-1].endswith("\n"):
184
+ target[-1] = target[-1][:-1]
185
+
186
+ _finalize_file(current_file, records)
187
+ return tuple(records)
188
+
189
+
190
+ def _optional_raw_value(value: bytes) -> str | None:
191
+ decoded = value.decode("ascii")
192
+ return None if not decoded.strip("0") else decoded
193
+
194
+
195
+ def _parse_raw_changes(raw_diff: bytes) -> dict[str, _RawFileChange]:
196
+ """Parse ``git diff --raw -z`` metadata without losing unusual path bytes."""
197
+
198
+ fields = raw_diff.split(b"\0")
199
+ changes: dict[str, _RawFileChange] = {}
200
+ index = 0
201
+ while index < len(fields) and fields[index]:
202
+ metadata = fields[index]
203
+ if index + 1 >= len(fields):
204
+ raise ValueError("Raw Git diff ended before its path field")
205
+ raw_path = fields[index + 1]
206
+ parts = metadata.removeprefix(b":").split(b" ")
207
+ if len(parts) != 5:
208
+ raise ValueError(f"Unexpected raw Git diff metadata: {metadata!r}")
209
+ base_mode, final_mode, base_oid, final_oid, status = parts
210
+ if status.startswith((b"R", b"C")):
211
+ raise ValueError("Raw Git diff unexpectedly reported a rename or copy")
212
+ path = raw_path.decode("utf-8", errors="surrogateescape")
213
+ changes[path] = _RawFileChange(
214
+ path=path,
215
+ base_oid=_optional_raw_value(base_oid),
216
+ final_oid=_optional_raw_value(final_oid),
217
+ base_mode=_optional_raw_value(base_mode),
218
+ final_mode=_optional_raw_value(final_mode),
219
+ )
220
+ index += 2
221
+ return changes
222
+
223
+
224
+ def diff_trees(repo: Path, base: str, final: str) -> tuple[RawDiffHunk, ...]:
225
+ """Read and parse a deterministic, zero-context tree diff."""
226
+
227
+ patch_output = run_git(
228
+ (
229
+ "-c",
230
+ "core.quotePath=false",
231
+ "diff",
232
+ "--no-color",
233
+ "--no-ext-diff",
234
+ "--no-textconv",
235
+ "--full-index",
236
+ "--unified=0",
237
+ "--no-renames",
238
+ base,
239
+ final,
240
+ "--",
241
+ ),
242
+ cwd=repo,
243
+ ).stdout
244
+ raw_output = run_git(
245
+ (
246
+ "diff",
247
+ "--raw",
248
+ "-z",
249
+ "--abbrev=40",
250
+ "--no-renames",
251
+ base,
252
+ final,
253
+ "--",
254
+ ),
255
+ cwd=repo,
256
+ ).stdout
257
+ changes = _parse_raw_changes(raw_output)
258
+ enriched: list[RawDiffHunk] = []
259
+ for hunk in parse_diff(patch_output):
260
+ try:
261
+ change = changes[hunk.path]
262
+ except KeyError as error:
263
+ raise ValueError(f"Patch path missing from raw Git diff: {hunk.path!r}") from error
264
+ enriched.append(
265
+ replace(
266
+ hunk,
267
+ base_oid=change.base_oid,
268
+ final_oid=change.final_oid,
269
+ base_mode=change.base_mode,
270
+ final_mode=change.final_mode,
271
+ )
272
+ )
273
+ return tuple(enriched)