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,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)
|