lgit-cli 3.7.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.
- lgit/__init__.py +75 -0
- lgit/__main__.py +8 -0
- lgit/analysis.py +326 -0
- lgit/api.py +1077 -0
- lgit/cache.py +338 -0
- lgit/changelog.py +523 -0
- lgit/cli.py +1104 -0
- lgit/compose.py +2110 -0
- lgit/config.py +437 -0
- lgit/diffing.py +384 -0
- lgit/errors.py +137 -0
- lgit/git.py +852 -0
- lgit/map_reduce.py +508 -0
- lgit/markdown_output.py +709 -0
- lgit/models.py +924 -0
- lgit/normalization.py +411 -0
- lgit/patch.py +784 -0
- lgit/profile.py +426 -0
- lgit/py.typed +0 -0
- lgit/repo.py +287 -0
- lgit/resources/__init__.py +1 -0
- lgit/resources/commit_types.json +242 -0
- lgit/resources/prompts/analysis/default.md +237 -0
- lgit/resources/prompts/analysis/markdown.md +112 -0
- lgit/resources/prompts/changelog/default.md +89 -0
- lgit/resources/prompts/changelog/markdown.md +60 -0
- lgit/resources/prompts/compose-bind/default.md +40 -0
- lgit/resources/prompts/compose-bind/markdown.md +41 -0
- lgit/resources/prompts/compose-intent/default.md +63 -0
- lgit/resources/prompts/compose-intent/markdown.md +59 -0
- lgit/resources/prompts/fast/default.md +46 -0
- lgit/resources/prompts/fast/markdown.md +51 -0
- lgit/resources/prompts/map/default.md +67 -0
- lgit/resources/prompts/map/markdown.md +63 -0
- lgit/resources/prompts/reduce/default.md +81 -0
- lgit/resources/prompts/reduce/markdown.md +68 -0
- lgit/resources/prompts/summary/default.md +74 -0
- lgit/resources/prompts/summary/markdown.md +77 -0
- lgit/resources/validation_data.json +1 -0
- lgit/rewrite.py +392 -0
- lgit/style.py +295 -0
- lgit/templates.py +385 -0
- lgit/testing/__init__.py +62 -0
- lgit/testing/compare.py +57 -0
- lgit/testing/fixture.py +386 -0
- lgit/testing/report.py +201 -0
- lgit/testing/runner.py +256 -0
- lgit/tokens.py +90 -0
- lgit/validation.py +545 -0
- lgit_cli-3.7.0.dist-info/METADATA +288 -0
- lgit_cli-3.7.0.dist-info/RECORD +54 -0
- lgit_cli-3.7.0.dist-info/WHEEL +4 -0
- lgit_cli-3.7.0.dist-info/entry_points.txt +2 -0
- lgit_cli-3.7.0.dist-info/licenses/LICENSE +21 -0
lgit/patch.py
ADDED
|
@@ -0,0 +1,784 @@
|
|
|
1
|
+
"""Compose snapshot parsing and isolated staging helpers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import stat
|
|
7
|
+
from collections import defaultdict
|
|
8
|
+
from collections.abc import Iterable, Sequence
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from enum import StrEnum
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from .errors import GitError, ValidationFailure
|
|
14
|
+
from .git import run_git, run_git_bytes
|
|
15
|
+
from .models import ComposeFile, ComposeHunk, ComposeSnapshot, WorktreePin, WorktreePinKind
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(slots=True)
|
|
19
|
+
class _ParsedHunk:
|
|
20
|
+
old_start: int
|
|
21
|
+
old_count: int
|
|
22
|
+
new_start: int
|
|
23
|
+
new_count: int
|
|
24
|
+
header: str
|
|
25
|
+
lines: list[str]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(slots=True)
|
|
29
|
+
class _ParsedFile:
|
|
30
|
+
path: str
|
|
31
|
+
header_lines: list[str]
|
|
32
|
+
hunks: list[_ParsedHunk] = field(default_factory=list)
|
|
33
|
+
additions: int = 0
|
|
34
|
+
deletions: int = 0
|
|
35
|
+
is_binary: bool = False
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class StageResult(StrEnum):
|
|
39
|
+
"""Outcome of staging a planned compose group."""
|
|
40
|
+
|
|
41
|
+
STAGED = "staged"
|
|
42
|
+
ALREADY_APPLIED = "already_applied"
|
|
43
|
+
EMPTY_PATCH = "empty_patch"
|
|
44
|
+
|
|
45
|
+
def combine(self, other: StageResult) -> StageResult:
|
|
46
|
+
"""Combine two staging outcomes, preferring materialized changes."""
|
|
47
|
+
if self == StageResult.STAGED or other == StageResult.STAGED:
|
|
48
|
+
return StageResult.STAGED
|
|
49
|
+
if self == StageResult.ALREADY_APPLIED or other == StageResult.ALREADY_APPLIED:
|
|
50
|
+
return StageResult.ALREADY_APPLIED
|
|
51
|
+
return StageResult.EMPTY_PATCH
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass(frozen=True, slots=True)
|
|
55
|
+
class SkippedFile:
|
|
56
|
+
"""A file whose selected patch could not apply cleanly to the index."""
|
|
57
|
+
|
|
58
|
+
path: str
|
|
59
|
+
reason: str
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass(frozen=True, slots=True)
|
|
63
|
+
class ComposeStageOutcome:
|
|
64
|
+
"""Result of staging a compose group into an index."""
|
|
65
|
+
|
|
66
|
+
result: StageResult
|
|
67
|
+
skipped: tuple[SkippedFile, ...] = ()
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@dataclass(frozen=True, slots=True)
|
|
71
|
+
class _FilePatch:
|
|
72
|
+
path: str
|
|
73
|
+
patch: str
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@dataclass(frozen=True, slots=True)
|
|
77
|
+
class _IndexBlob:
|
|
78
|
+
path: str
|
|
79
|
+
mode: str
|
|
80
|
+
oid: str | None = None
|
|
81
|
+
contents: bytes | None = None
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@dataclass(frozen=True, slots=True)
|
|
85
|
+
class ComposeGroupPatch:
|
|
86
|
+
"""Patch, stat, and staging actions for one executable compose group."""
|
|
87
|
+
|
|
88
|
+
diff: str
|
|
89
|
+
stat: str
|
|
90
|
+
apply_patches: tuple[_FilePatch, ...] = ()
|
|
91
|
+
fallback_files: tuple[str, ...] = ()
|
|
92
|
+
index_blobs: tuple[_IndexBlob, ...] = ()
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def build_compose_snapshot(diff: str, stat: str) -> ComposeSnapshot:
|
|
96
|
+
"""Parse a compose diff into stable file and hunk identifiers."""
|
|
97
|
+
parsed_files: list[_ParsedFile] = []
|
|
98
|
+
current_file: _ParsedFile | None = None
|
|
99
|
+
current_hunk: _ParsedHunk | None = None
|
|
100
|
+
|
|
101
|
+
def finish_hunk() -> None:
|
|
102
|
+
nonlocal current_hunk
|
|
103
|
+
if current_file is not None and current_hunk is not None:
|
|
104
|
+
current_file.hunks.append(current_hunk)
|
|
105
|
+
current_hunk = None
|
|
106
|
+
|
|
107
|
+
def finish_file() -> None:
|
|
108
|
+
nonlocal current_file
|
|
109
|
+
finish_hunk()
|
|
110
|
+
if current_file is not None:
|
|
111
|
+
parsed_files.append(current_file)
|
|
112
|
+
current_file = None
|
|
113
|
+
|
|
114
|
+
for line in _diff_lines_preserve_cr(diff):
|
|
115
|
+
if line.startswith("diff --git "):
|
|
116
|
+
finish_file()
|
|
117
|
+
current_file = _ParsedFile(path=_parse_file_path(line), header_lines=[line])
|
|
118
|
+
continue
|
|
119
|
+
if current_file is None:
|
|
120
|
+
continue
|
|
121
|
+
if line.startswith("@@ "):
|
|
122
|
+
finish_hunk()
|
|
123
|
+
old_start, old_count, new_start, new_count = _parse_hunk_header(line)
|
|
124
|
+
current_hunk = _ParsedHunk(
|
|
125
|
+
old_start=old_start,
|
|
126
|
+
old_count=old_count,
|
|
127
|
+
new_start=new_start,
|
|
128
|
+
new_count=new_count,
|
|
129
|
+
header=line,
|
|
130
|
+
lines=[line],
|
|
131
|
+
)
|
|
132
|
+
continue
|
|
133
|
+
if current_hunk is not None:
|
|
134
|
+
if line.startswith("+"):
|
|
135
|
+
current_file.additions += 1
|
|
136
|
+
elif line.startswith("-"):
|
|
137
|
+
current_file.deletions += 1
|
|
138
|
+
current_hunk.lines.append(line)
|
|
139
|
+
continue
|
|
140
|
+
if line.startswith("Binary files "):
|
|
141
|
+
current_file.is_binary = True
|
|
142
|
+
current_file.header_lines.append(line)
|
|
143
|
+
finish_file()
|
|
144
|
+
|
|
145
|
+
files: list[ComposeFile] = []
|
|
146
|
+
hunks: list[ComposeHunk] = []
|
|
147
|
+
for file_index, parsed in enumerate(parsed_files, start=1):
|
|
148
|
+
file_id = f"F{file_index:03d}"
|
|
149
|
+
patch_header = _join_lines(parsed.header_lines)
|
|
150
|
+
full_patch = patch_header
|
|
151
|
+
hunk_ids: list[str] = []
|
|
152
|
+
if not parsed.hunks:
|
|
153
|
+
hunk_id = f"{file_id}-H001"
|
|
154
|
+
snippet = _build_synthetic_snippet(parsed)
|
|
155
|
+
hunk_ids.append(hunk_id)
|
|
156
|
+
hunks.append(
|
|
157
|
+
ComposeHunk(
|
|
158
|
+
hunk_id=hunk_id,
|
|
159
|
+
file_id=file_id,
|
|
160
|
+
path=parsed.path,
|
|
161
|
+
old_start=0,
|
|
162
|
+
old_count=0,
|
|
163
|
+
new_start=0,
|
|
164
|
+
new_count=0,
|
|
165
|
+
header=snippet,
|
|
166
|
+
raw_patch="",
|
|
167
|
+
snippet=snippet,
|
|
168
|
+
semantic_key=_build_semantic_key(parsed.path, parsed.header_lines, snippet),
|
|
169
|
+
synthetic=True,
|
|
170
|
+
)
|
|
171
|
+
)
|
|
172
|
+
else:
|
|
173
|
+
for hunk_index, hunk in enumerate(parsed.hunks, start=1):
|
|
174
|
+
hunk_id = f"{file_id}-H{hunk_index:03d}"
|
|
175
|
+
raw_patch = _join_lines(hunk.lines)
|
|
176
|
+
snippet = _build_hunk_snippet(hunk.lines, hunk.header)
|
|
177
|
+
hunk_ids.append(hunk_id)
|
|
178
|
+
full_patch += raw_patch
|
|
179
|
+
hunks.append(
|
|
180
|
+
ComposeHunk(
|
|
181
|
+
hunk_id=hunk_id,
|
|
182
|
+
file_id=file_id,
|
|
183
|
+
path=parsed.path,
|
|
184
|
+
old_start=hunk.old_start,
|
|
185
|
+
old_count=hunk.old_count,
|
|
186
|
+
new_start=hunk.new_start,
|
|
187
|
+
new_count=hunk.new_count,
|
|
188
|
+
header=hunk.header,
|
|
189
|
+
raw_patch=raw_patch,
|
|
190
|
+
snippet=snippet,
|
|
191
|
+
semantic_key=_build_semantic_key(parsed.path, hunk.lines, snippet),
|
|
192
|
+
)
|
|
193
|
+
)
|
|
194
|
+
hunk_word = "hunk" if len(hunk_ids) == 1 else "hunks"
|
|
195
|
+
files.append(
|
|
196
|
+
ComposeFile(
|
|
197
|
+
file_id=file_id,
|
|
198
|
+
path=parsed.path,
|
|
199
|
+
patch_header=patch_header,
|
|
200
|
+
full_patch=full_patch,
|
|
201
|
+
summary=f"{parsed.path} (+{parsed.additions}/-{parsed.deletions}, {len(hunk_ids)} {hunk_word})",
|
|
202
|
+
hunk_ids=tuple(hunk_ids),
|
|
203
|
+
additions=parsed.additions,
|
|
204
|
+
deletions=parsed.deletions,
|
|
205
|
+
is_binary=parsed.is_binary,
|
|
206
|
+
synthetic_only=not parsed.hunks,
|
|
207
|
+
)
|
|
208
|
+
)
|
|
209
|
+
return ComposeSnapshot(diff=diff, stat=stat, files=tuple(files), hunks=tuple(hunks), pins={})
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def pin_snapshot_worktree_state(snapshot: ComposeSnapshot, dir: str | os.PathLike[str] = ".") -> ComposeSnapshot:
|
|
213
|
+
"""Pin snapshot paths to object ids captured from the current worktree."""
|
|
214
|
+
root = Path(dir)
|
|
215
|
+
regular_paths: list[str] = []
|
|
216
|
+
for file in snapshot.files:
|
|
217
|
+
full_path = root / file.path
|
|
218
|
+
try:
|
|
219
|
+
metadata = full_path.lstat()
|
|
220
|
+
# Keep this before OSError so missing paths are recorded as deletions.
|
|
221
|
+
except FileNotFoundError:
|
|
222
|
+
snapshot.pins[file.path] = WorktreePin.deleted()
|
|
223
|
+
continue
|
|
224
|
+
except OSError as exc:
|
|
225
|
+
raise GitError(f"Failed to inspect worktree path {full_path}: {exc}") from exc
|
|
226
|
+
if stat.S_ISLNK(metadata.st_mode):
|
|
227
|
+
target = os.readlink(full_path)
|
|
228
|
+
oid = _hash_blob_bytes(os.fsencode(target), file.path, dir)
|
|
229
|
+
snapshot.pins[file.path] = WorktreePin.object(mode="120000", oid=oid)
|
|
230
|
+
elif stat.S_ISDIR(metadata.st_mode):
|
|
231
|
+
oid = _submodule_head(full_path)
|
|
232
|
+
if oid:
|
|
233
|
+
snapshot.pins[file.path] = WorktreePin.object(mode="160000", oid=oid)
|
|
234
|
+
elif "\n" not in file.path:
|
|
235
|
+
regular_paths.append(file.path)
|
|
236
|
+
|
|
237
|
+
for path, oid in zip(regular_paths, _hash_worktree_paths(regular_paths, dir), strict=True):
|
|
238
|
+
snapshot.pins[path] = WorktreePin.object(mode=_worktree_file_mode(root / path), oid=oid)
|
|
239
|
+
return snapshot
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def create_executable_group_patch(snapshot: ComposeSnapshot, group: object) -> ComposeGroupPatch:
|
|
243
|
+
"""Create the exact patch/stat and staging actions for a planned group."""
|
|
244
|
+
selected_by_file = _selected_hunks_by_file(snapshot, group)
|
|
245
|
+
diff_parts: list[str] = []
|
|
246
|
+
stat_parts: list[str] = []
|
|
247
|
+
apply_patches: list[_FilePatch] = []
|
|
248
|
+
fallback_files: list[str] = []
|
|
249
|
+
index_blobs: list[_IndexBlob] = []
|
|
250
|
+
|
|
251
|
+
for file in snapshot.files:
|
|
252
|
+
selected_for_file = selected_by_file.get(file.file_id)
|
|
253
|
+
if not selected_for_file:
|
|
254
|
+
continue
|
|
255
|
+
ordered_hunks = _ordered_selected_hunks(file, selected_for_file)
|
|
256
|
+
if file.synthetic_only or file.is_binary:
|
|
257
|
+
if not _selected_hunks_cover_file(file, selected_for_file):
|
|
258
|
+
raise ValidationFailure(
|
|
259
|
+
f"group {_group_id(group)} cannot partially stage unpatchable file {file.path}",
|
|
260
|
+
field="compose",
|
|
261
|
+
)
|
|
262
|
+
if file.synthetic_only and not file.is_binary and _new_file_mode(file):
|
|
263
|
+
index_blobs.append(_new_file_index_blob(file, ordered_hunks))
|
|
264
|
+
else:
|
|
265
|
+
fallback_files.append(file.path)
|
|
266
|
+
diff_parts.append(file.full_patch)
|
|
267
|
+
stat_parts.append(_stat_line(file.path, file.additions, file.deletions, file.is_binary))
|
|
268
|
+
continue
|
|
269
|
+
|
|
270
|
+
file_patch = _create_patch_for_file(file, ordered_hunks)
|
|
271
|
+
additions, deletions = _count_hunk_changes(ordered_hunks)
|
|
272
|
+
diff_parts.append(file_patch)
|
|
273
|
+
if _new_file_mode(file):
|
|
274
|
+
if _selected_hunks_cover_file(file, selected_for_file):
|
|
275
|
+
index_blobs.append(_new_file_index_blob(file, ordered_hunks))
|
|
276
|
+
else:
|
|
277
|
+
apply_patches.append(_FilePatch(file.path, file_patch))
|
|
278
|
+
elif _selected_hunks_cover_file(file, selected_for_file):
|
|
279
|
+
fallback_files.append(file.path)
|
|
280
|
+
else:
|
|
281
|
+
apply_patches.append(_FilePatch(file.path, file_patch))
|
|
282
|
+
stat_parts.append(_stat_line(file.path, additions, deletions, False))
|
|
283
|
+
|
|
284
|
+
return ComposeGroupPatch(
|
|
285
|
+
diff="".join(diff_parts),
|
|
286
|
+
stat="".join(stat_parts),
|
|
287
|
+
apply_patches=tuple(apply_patches),
|
|
288
|
+
fallback_files=tuple(sorted(set(fallback_files))),
|
|
289
|
+
index_blobs=tuple(index_blobs),
|
|
290
|
+
)
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def stage_executable_group_in_index(
|
|
294
|
+
snapshot: ComposeSnapshot,
|
|
295
|
+
group: object,
|
|
296
|
+
dir: str | os.PathLike[str],
|
|
297
|
+
index_file: str | os.PathLike[str],
|
|
298
|
+
) -> ComposeStageOutcome:
|
|
299
|
+
"""Stage a planned group into a temporary index without reading live files unless unpinned."""
|
|
300
|
+
group_patch = create_executable_group_patch(snapshot, group)
|
|
301
|
+
result = StageResult.EMPTY_PATCH
|
|
302
|
+
skipped: list[SkippedFile] = []
|
|
303
|
+
|
|
304
|
+
for file_patch in group_patch.apply_patches:
|
|
305
|
+
outcome, reason = _apply_file_patch_to_index(file_patch.patch, dir, index_file)
|
|
306
|
+
if outcome == "staged":
|
|
307
|
+
result = result.combine(StageResult.STAGED)
|
|
308
|
+
elif outcome == "already":
|
|
309
|
+
result = result.combine(StageResult.ALREADY_APPLIED)
|
|
310
|
+
elif outcome == "empty":
|
|
311
|
+
result = result.combine(StageResult.EMPTY_PATCH)
|
|
312
|
+
else:
|
|
313
|
+
_restore_index_path_to_head(file_patch.path, dir, index_file)
|
|
314
|
+
skipped.append(SkippedFile(file_patch.path, reason or "git apply failed"))
|
|
315
|
+
|
|
316
|
+
for path in group_patch.fallback_files:
|
|
317
|
+
pin = snapshot.pins.get(path)
|
|
318
|
+
if pin is None:
|
|
319
|
+
run_git(["add", "--", path], cwd=dir, index_file=index_file)
|
|
320
|
+
result = result.combine(StageResult.STAGED)
|
|
321
|
+
elif pin.kind == WorktreePinKind.DELETED:
|
|
322
|
+
result = result.combine(_remove_index_path(path, dir, index_file))
|
|
323
|
+
else:
|
|
324
|
+
result = result.combine(
|
|
325
|
+
_stage_index_blob(_IndexBlob(path=path, mode=pin.mode or "100644", oid=pin.oid), dir, index_file)
|
|
326
|
+
)
|
|
327
|
+
|
|
328
|
+
for blob in group_patch.index_blobs:
|
|
329
|
+
result = result.combine(_stage_index_blob(blob, dir, index_file))
|
|
330
|
+
|
|
331
|
+
return ComposeStageOutcome(result=result, skipped=tuple(skipped))
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def force_stage_file_from_base_in_index(
|
|
335
|
+
snapshot: ComposeSnapshot,
|
|
336
|
+
file_id: str,
|
|
337
|
+
selected_hunk_ids: Sequence[str],
|
|
338
|
+
dir: str | os.PathLike[str],
|
|
339
|
+
index_file: str | os.PathLike[str],
|
|
340
|
+
) -> None:
|
|
341
|
+
"""Rewrite one index entry as base blob plus selected hunks from the snapshot."""
|
|
342
|
+
file = snapshot.file_by_id(file_id)
|
|
343
|
+
if file is None:
|
|
344
|
+
raise ValidationFailure(f"unknown compose file id {file_id}", field="compose")
|
|
345
|
+
ordered = [
|
|
346
|
+
hunk
|
|
347
|
+
for hunk_id in file.hunk_ids
|
|
348
|
+
if hunk_id in set(selected_hunk_ids)
|
|
349
|
+
for hunk in [snapshot.hunk_by_id(hunk_id)]
|
|
350
|
+
if hunk is not None and hunk.raw_patch
|
|
351
|
+
]
|
|
352
|
+
if not ordered:
|
|
353
|
+
return
|
|
354
|
+
_restore_index_path_to_head(file.path, dir, index_file)
|
|
355
|
+
base_bytes, mode = _resolve_base_blob(file, dir)
|
|
356
|
+
target = _splice_hunks_into_base(base_bytes, ordered)
|
|
357
|
+
_stage_index_blob(_IndexBlob(path=file.path, mode=mode, contents=target), dir, index_file)
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def _parse_hunk_header(header: str) -> tuple[int, int, int, int]:
|
|
361
|
+
trimmed = header.strip()
|
|
362
|
+
if not trimmed.startswith("@@"):
|
|
363
|
+
raise ValidationFailure(f"failed to parse hunk header {header!r}", field="diff")
|
|
364
|
+
middle = trimmed.removeprefix("@@").split("@@", 1)[0].strip().split()
|
|
365
|
+
if len(middle) < 2:
|
|
366
|
+
raise ValidationFailure(f"failed to parse hunk header {header!r}", field="diff")
|
|
367
|
+
|
|
368
|
+
def parse_range(raw: str, prefix: str) -> tuple[int, int]:
|
|
369
|
+
if not raw.startswith(prefix):
|
|
370
|
+
raise ValueError(f"hunk range {raw!r} does not start with {prefix!r}")
|
|
371
|
+
body = raw.removeprefix(prefix)
|
|
372
|
+
if "," in body:
|
|
373
|
+
start, count = body.split(",", 1)
|
|
374
|
+
return int(start), int(count)
|
|
375
|
+
return int(body), 1
|
|
376
|
+
|
|
377
|
+
try:
|
|
378
|
+
old_start, old_count = parse_range(middle[0], "-")
|
|
379
|
+
new_start, new_count = parse_range(middle[1], "+")
|
|
380
|
+
except ValueError as exc:
|
|
381
|
+
raise ValidationFailure(f"failed to parse hunk header {header!r}", field="diff") from exc
|
|
382
|
+
return old_start, old_count, new_start, new_count
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
def _parse_file_path(diff_header: str) -> str:
|
|
386
|
+
parts = diff_header.split()
|
|
387
|
+
if len(parts) >= 4 and parts[3].startswith("b/"):
|
|
388
|
+
return parts[3][2:]
|
|
389
|
+
raise ValidationFailure(f"failed to parse file path from {diff_header!r}", field="diff")
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def _diff_lines_preserve_cr(input: str) -> Iterable[str]:
|
|
393
|
+
for line in input.splitlines(keepends=True):
|
|
394
|
+
yield line[:-1] if line.endswith("\n") else line
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
def _join_lines(lines: Sequence[str]) -> str:
|
|
398
|
+
return "" if not lines else "\n".join(lines) + "\n"
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
def _truncate_snippet(snippet: str, max_chars: int) -> str:
|
|
402
|
+
trimmed = snippet.strip()
|
|
403
|
+
return trimmed if len(trimmed) <= max_chars else trimmed[:max_chars] + "..."
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def _build_hunk_snippet(lines: Sequence[str], fallback: str) -> str:
|
|
407
|
+
interesting = [
|
|
408
|
+
_truncate_snippet(line.lstrip("+-"), 80) for line in lines[1:] if line.startswith("+") or line.startswith("-")
|
|
409
|
+
][:3]
|
|
410
|
+
return " | ".join(interesting) if interesting else _truncate_snippet(fallback, 80)
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
def _build_synthetic_snippet(file: _ParsedFile) -> str:
|
|
414
|
+
for line in file.header_lines[1:]:
|
|
415
|
+
if not line.startswith(("index ", "--- ", "+++ ")) and line.strip():
|
|
416
|
+
return _truncate_snippet(line, 80)
|
|
417
|
+
return _truncate_snippet(f"whole-file change in {file.path}", 80)
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
def _fnv1a_64(input: str) -> str:
|
|
421
|
+
value = 0xCBF29CE484222325
|
|
422
|
+
for byte in input.encode():
|
|
423
|
+
value ^= byte
|
|
424
|
+
value = (value * 0x100000001B3) & 0xFFFFFFFFFFFFFFFF
|
|
425
|
+
return f"{value:016x}"
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
def _build_semantic_key(path: str, lines: Sequence[str], fallback: str) -> str:
|
|
429
|
+
changed = [
|
|
430
|
+
line
|
|
431
|
+
for line in lines
|
|
432
|
+
if (line.startswith("+") and not line.startswith("+++"))
|
|
433
|
+
or (line.startswith("-") and not line.startswith("---"))
|
|
434
|
+
]
|
|
435
|
+
return f"{path}:{_fnv1a_64(chr(10).join(changed) if changed else fallback)}"
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
def _group_id(group: object) -> str:
|
|
439
|
+
return str(getattr(group, "group_id", getattr(group, "id", "compose-group")))
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
def _group_hunk_ids(snapshot: ComposeSnapshot, group: object) -> tuple[str, ...]:
|
|
443
|
+
hunk_ids = getattr(group, "hunk_ids", None)
|
|
444
|
+
if hunk_ids:
|
|
445
|
+
return tuple(str(hunk_id) for hunk_id in hunk_ids)
|
|
446
|
+
changes = getattr(group, "changes", None)
|
|
447
|
+
if changes:
|
|
448
|
+
selected: list[str] = []
|
|
449
|
+
for change in changes:
|
|
450
|
+
file = snapshot.file_by_path(str(change.path))
|
|
451
|
+
if file is None:
|
|
452
|
+
continue
|
|
453
|
+
selectors = tuple(getattr(change, "hunks", ()))
|
|
454
|
+
if not selectors or any(getattr(selector, "kind", "").upper() == "ALL" for selector in selectors):
|
|
455
|
+
selected.extend(file.hunk_ids)
|
|
456
|
+
continue
|
|
457
|
+
for hunk in snapshot.hunks_for_file(file.file_id):
|
|
458
|
+
if _hunk_selected(hunk, selectors):
|
|
459
|
+
selected.append(hunk.hunk_id)
|
|
460
|
+
return tuple(dict.fromkeys(selected))
|
|
461
|
+
file_ids = getattr(group, "file_ids", None)
|
|
462
|
+
if file_ids:
|
|
463
|
+
selected = []
|
|
464
|
+
wanted = {str(file_id) for file_id in file_ids}
|
|
465
|
+
for file in snapshot.files:
|
|
466
|
+
if file.file_id in wanted:
|
|
467
|
+
selected.extend(file.hunk_ids)
|
|
468
|
+
return tuple(selected)
|
|
469
|
+
return ()
|
|
470
|
+
|
|
471
|
+
|
|
472
|
+
def _hunk_selected(hunk: ComposeHunk, selectors: Sequence[object]) -> bool:
|
|
473
|
+
for selector in selectors:
|
|
474
|
+
kind = str(getattr(selector, "kind", "")).upper()
|
|
475
|
+
if kind == "ALL":
|
|
476
|
+
return True
|
|
477
|
+
if kind == "LINES":
|
|
478
|
+
start = getattr(selector, "start", None)
|
|
479
|
+
end = getattr(selector, "end", None)
|
|
480
|
+
if (
|
|
481
|
+
start is not None
|
|
482
|
+
and end is not None
|
|
483
|
+
and hunk.new_start <= int(end)
|
|
484
|
+
and hunk.new_start + hunk.new_count >= int(start)
|
|
485
|
+
):
|
|
486
|
+
return True
|
|
487
|
+
if kind == "SEARCH":
|
|
488
|
+
pattern = getattr(selector, "pattern", None)
|
|
489
|
+
if pattern and str(pattern) in hunk.raw_patch:
|
|
490
|
+
return True
|
|
491
|
+
return False
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
def _selected_hunks_by_file(snapshot: ComposeSnapshot, group: object) -> dict[str, list[ComposeHunk]]:
|
|
495
|
+
hunk_ids = _group_hunk_ids(snapshot, group)
|
|
496
|
+
if not hunk_ids:
|
|
497
|
+
raise ValidationFailure(f"group {_group_id(group)} has no assigned hunks", field="compose")
|
|
498
|
+
selected: dict[str, list[ComposeHunk]] = defaultdict(list)
|
|
499
|
+
for hunk_id in hunk_ids:
|
|
500
|
+
hunk = snapshot.hunk_by_id(hunk_id)
|
|
501
|
+
if hunk is None:
|
|
502
|
+
raise ValidationFailure(f"group {_group_id(group)} references unknown hunk id {hunk_id}", field="compose")
|
|
503
|
+
selected[hunk.file_id].append(hunk)
|
|
504
|
+
return dict(selected)
|
|
505
|
+
|
|
506
|
+
|
|
507
|
+
def _ordered_selected_hunks(file: ComposeFile, selected_for_file: Sequence[ComposeHunk]) -> list[ComposeHunk]:
|
|
508
|
+
by_id = {hunk.hunk_id: hunk for hunk in selected_for_file}
|
|
509
|
+
ordered = [by_id[hunk_id] for hunk_id in file.hunk_ids if hunk_id in by_id]
|
|
510
|
+
if not ordered:
|
|
511
|
+
raise ValidationFailure(f"selected no patchable hunks for {file.path}", field="compose")
|
|
512
|
+
return ordered
|
|
513
|
+
|
|
514
|
+
|
|
515
|
+
def _selected_hunks_cover_file(file: ComposeFile, selected_for_file: Sequence[ComposeHunk]) -> bool:
|
|
516
|
+
return {hunk.hunk_id for hunk in selected_for_file} == set(file.hunk_ids)
|
|
517
|
+
|
|
518
|
+
|
|
519
|
+
def _create_patch_for_file(file: ComposeFile, hunks: Sequence[ComposeHunk]) -> str:
|
|
520
|
+
return file.patch_header + "".join(hunk.raw_patch for hunk in hunks)
|
|
521
|
+
|
|
522
|
+
|
|
523
|
+
def _count_hunk_changes(hunks: Sequence[ComposeHunk]) -> tuple[int, int]:
|
|
524
|
+
additions = deletions = 0
|
|
525
|
+
for hunk in hunks:
|
|
526
|
+
for line in hunk.raw_patch.splitlines():
|
|
527
|
+
if line.startswith("+"):
|
|
528
|
+
additions += 1
|
|
529
|
+
elif line.startswith("-"):
|
|
530
|
+
deletions += 1
|
|
531
|
+
return additions, deletions
|
|
532
|
+
|
|
533
|
+
|
|
534
|
+
def _stat_line(path: str, additions: int, deletions: int, is_binary: bool) -> str:
|
|
535
|
+
if is_binary and additions == 0 and deletions == 0:
|
|
536
|
+
return f" {path} | Bin\n"
|
|
537
|
+
return f" {path} | {additions + deletions} {'+' * min(additions, 50)}{'-' * min(deletions, 50)}\n"
|
|
538
|
+
|
|
539
|
+
|
|
540
|
+
def _new_file_mode(file: ComposeFile) -> str | None:
|
|
541
|
+
for line in file.patch_header.splitlines():
|
|
542
|
+
if line.startswith("new file mode "):
|
|
543
|
+
return line.removeprefix("new file mode ").strip()
|
|
544
|
+
return None
|
|
545
|
+
|
|
546
|
+
|
|
547
|
+
def _validate_new_file_mode(file: ComposeFile) -> str:
|
|
548
|
+
mode = _new_file_mode(file) or "100644"
|
|
549
|
+
if mode not in {"100644", "100755", "120000", "160000"}:
|
|
550
|
+
raise ValidationFailure(f"invalid new file mode {mode!r} for {file.path}", field="diff")
|
|
551
|
+
return mode
|
|
552
|
+
|
|
553
|
+
|
|
554
|
+
def _materialize_new_file_contents(hunks: Sequence[ComposeHunk]) -> bytes:
|
|
555
|
+
out = bytearray()
|
|
556
|
+
last_had_newline = False
|
|
557
|
+
for hunk in hunks:
|
|
558
|
+
for line in _diff_lines_preserve_cr(hunk.raw_patch):
|
|
559
|
+
if line.startswith("@@"):
|
|
560
|
+
last_had_newline = False
|
|
561
|
+
elif line == r"":
|
|
562
|
+
if last_had_newline and out.endswith(b"\n"):
|
|
563
|
+
out.pop()
|
|
564
|
+
last_had_newline = False
|
|
565
|
+
elif line.startswith("+") or line.startswith(" "):
|
|
566
|
+
out.extend(line[1:].encode())
|
|
567
|
+
out.extend(b"\n")
|
|
568
|
+
last_had_newline = True
|
|
569
|
+
else:
|
|
570
|
+
last_had_newline = False
|
|
571
|
+
return bytes(out)
|
|
572
|
+
|
|
573
|
+
|
|
574
|
+
def _new_file_index_blob(file: ComposeFile, hunks: Sequence[ComposeHunk]) -> _IndexBlob:
|
|
575
|
+
mode = _validate_new_file_mode(file)
|
|
576
|
+
if mode == "160000":
|
|
577
|
+
oid = _materialize_gitlink_oid(file, hunks)
|
|
578
|
+
return _IndexBlob(path=file.path, mode=mode, oid=oid)
|
|
579
|
+
return _IndexBlob(path=file.path, mode=mode, contents=_materialize_new_file_contents(hunks))
|
|
580
|
+
|
|
581
|
+
|
|
582
|
+
def _materialize_gitlink_oid(file: ComposeFile, hunks: Sequence[ComposeHunk]) -> str:
|
|
583
|
+
for line in _materialize_new_file_contents(hunks).decode(errors="replace").splitlines():
|
|
584
|
+
if line.startswith("Subproject commit "):
|
|
585
|
+
return _validate_git_object_id(line.removeprefix("Subproject commit ").split()[0], file)
|
|
586
|
+
for line in file.patch_header.splitlines():
|
|
587
|
+
if line.startswith("index ") and ".." in line:
|
|
588
|
+
return _validate_git_object_id(line.split()[1].split("..", 1)[1], file)
|
|
589
|
+
raise ValidationFailure(f"missing gitlink object id for {file.path}", field="diff")
|
|
590
|
+
|
|
591
|
+
|
|
592
|
+
def _validate_git_object_id(oid: str, file: ComposeFile) -> str:
|
|
593
|
+
value = oid.strip()
|
|
594
|
+
if value and all(ch in "0123456789abcdefABCDEF" for ch in value) and any(ch != "0" for ch in value):
|
|
595
|
+
return value
|
|
596
|
+
raise ValidationFailure(f"invalid gitlink object id {oid!r} for {file.path}", field="diff")
|
|
597
|
+
|
|
598
|
+
|
|
599
|
+
def _apply_file_patch_to_index(
|
|
600
|
+
patch: str,
|
|
601
|
+
dir: str | os.PathLike[str],
|
|
602
|
+
index_file: str | os.PathLike[str] | None,
|
|
603
|
+
) -> tuple[str, str | None]:
|
|
604
|
+
if not patch.strip():
|
|
605
|
+
return "empty", None
|
|
606
|
+
reverse = run_git(
|
|
607
|
+
["apply", "--cached", "--reverse", "--check", "--recount"],
|
|
608
|
+
cwd=dir,
|
|
609
|
+
input_text=patch,
|
|
610
|
+
check=False,
|
|
611
|
+
index_file=index_file,
|
|
612
|
+
)
|
|
613
|
+
if reverse.returncode == 0:
|
|
614
|
+
return "already", None
|
|
615
|
+
applied = run_git(
|
|
616
|
+
["apply", "--cached", "--3way", "--recount"],
|
|
617
|
+
cwd=dir,
|
|
618
|
+
input_text=patch,
|
|
619
|
+
check=False,
|
|
620
|
+
index_file=index_file,
|
|
621
|
+
)
|
|
622
|
+
if applied.returncode == 0:
|
|
623
|
+
return "staged", None
|
|
624
|
+
return "failed", applied.stderr.strip()
|
|
625
|
+
|
|
626
|
+
|
|
627
|
+
def _restore_index_path_to_head(
|
|
628
|
+
path: str, dir: str | os.PathLike[str], index_file: str | os.PathLike[str] | None
|
|
629
|
+
) -> None:
|
|
630
|
+
run_git(["reset", "-q", "HEAD", "--", path], cwd=dir, index_file=index_file)
|
|
631
|
+
|
|
632
|
+
|
|
633
|
+
def _remove_index_path(
|
|
634
|
+
path: str, dir: str | os.PathLike[str], index_file: str | os.PathLike[str] | None
|
|
635
|
+
) -> StageResult:
|
|
636
|
+
listed = run_git(["ls-files", "--", path], cwd=dir, index_file=index_file)
|
|
637
|
+
if not listed.stdout:
|
|
638
|
+
return StageResult.ALREADY_APPLIED
|
|
639
|
+
run_git(["update-index", "--force-remove", "--", path], cwd=dir, index_file=index_file)
|
|
640
|
+
return StageResult.STAGED
|
|
641
|
+
|
|
642
|
+
|
|
643
|
+
def _stage_index_blob(
|
|
644
|
+
blob: _IndexBlob, dir: str | os.PathLike[str], index_file: str | os.PathLike[str] | None
|
|
645
|
+
) -> StageResult:
|
|
646
|
+
oid = blob.oid if blob.oid is not None else _hash_blob_bytes(blob.contents or b"", blob.path, dir)
|
|
647
|
+
current = run_git(["ls-files", "-s", "--", blob.path], cwd=dir, index_file=index_file)
|
|
648
|
+
parts = current.stdout.split()
|
|
649
|
+
if len(parts) >= 2 and parts[0] == blob.mode and parts[1] == oid:
|
|
650
|
+
return StageResult.ALREADY_APPLIED
|
|
651
|
+
run_git(["update-index", "--add", "--cacheinfo", f"{blob.mode},{oid},{blob.path}"], cwd=dir, index_file=index_file)
|
|
652
|
+
return StageResult.STAGED
|
|
653
|
+
|
|
654
|
+
|
|
655
|
+
def _hash_blob_bytes(contents: bytes, path: str, dir: str | os.PathLike[str]) -> str:
|
|
656
|
+
result = run_git_bytes(["hash-object", "-w", "--stdin"], cwd=dir, input_bytes=contents)
|
|
657
|
+
oid = result.stdout.decode("utf-8", errors="strict").strip()
|
|
658
|
+
if not oid:
|
|
659
|
+
raise GitError(f"git hash-object returned empty oid for {path}")
|
|
660
|
+
return oid
|
|
661
|
+
|
|
662
|
+
|
|
663
|
+
def _hash_worktree_paths(paths: Sequence[str], dir: str | os.PathLike[str]) -> list[str]:
|
|
664
|
+
if not paths:
|
|
665
|
+
return []
|
|
666
|
+
result = run_git(["hash-object", "-w", "--stdin-paths"], cwd=dir, input_text="\n".join(paths) + "\n")
|
|
667
|
+
oids = result.stdout.splitlines()
|
|
668
|
+
if len(oids) != len(paths):
|
|
669
|
+
raise GitError(f"git hash-object returned {len(oids)} oids for {len(paths)} paths")
|
|
670
|
+
return oids
|
|
671
|
+
|
|
672
|
+
|
|
673
|
+
def _worktree_file_mode(path: Path) -> str:
|
|
674
|
+
try:
|
|
675
|
+
mode = path.stat().st_mode
|
|
676
|
+
except OSError:
|
|
677
|
+
return "100644"
|
|
678
|
+
return "100755" if mode & (stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH) else "100644"
|
|
679
|
+
|
|
680
|
+
|
|
681
|
+
def _submodule_head(path: Path) -> str | None:
|
|
682
|
+
result = run_git(["rev-parse", "HEAD"], cwd=path, check=False)
|
|
683
|
+
oid = result.stdout.strip()
|
|
684
|
+
return oid if result.returncode == 0 and oid else None
|
|
685
|
+
|
|
686
|
+
|
|
687
|
+
def _resolve_base_blob(file: ComposeFile, dir: str | os.PathLike[str]) -> tuple[bytes, str]:
|
|
688
|
+
index_line = next((line for line in file.patch_header.splitlines() if line.startswith("index ")), None)
|
|
689
|
+
base_oid = None
|
|
690
|
+
if index_line:
|
|
691
|
+
range_part = index_line.removeprefix("index ").split()[0]
|
|
692
|
+
if ".." in range_part:
|
|
693
|
+
base_oid = range_part.split("..", 1)[0]
|
|
694
|
+
if base_oid and any(ch != "0" for ch in base_oid):
|
|
695
|
+
full = run_git(["rev-parse", "--verify", "--quiet", f"{base_oid}^{{blob}}"], cwd=dir).stdout.strip()
|
|
696
|
+
data = run_git_bytes(["cat-file", "blob", full], cwd=dir).stdout
|
|
697
|
+
mode = "100644"
|
|
698
|
+
if index_line and len(index_line.split()) > 2:
|
|
699
|
+
mode = index_line.split()[2]
|
|
700
|
+
else:
|
|
701
|
+
old_mode = next(
|
|
702
|
+
(
|
|
703
|
+
line.removeprefix("old mode ").strip()
|
|
704
|
+
for line in file.patch_header.splitlines()
|
|
705
|
+
if line.startswith("old mode ")
|
|
706
|
+
),
|
|
707
|
+
None,
|
|
708
|
+
)
|
|
709
|
+
if old_mode:
|
|
710
|
+
mode = old_mode
|
|
711
|
+
return data, mode
|
|
712
|
+
return b"", _new_file_mode(file) or "100644"
|
|
713
|
+
|
|
714
|
+
|
|
715
|
+
def _split_lines_keep_eol(data: bytes) -> list[bytes]:
|
|
716
|
+
if not data:
|
|
717
|
+
return []
|
|
718
|
+
return data.splitlines(keepends=True)
|
|
719
|
+
|
|
720
|
+
|
|
721
|
+
def _dominant_eol(lines: Sequence[bytes]) -> bytes:
|
|
722
|
+
crlf = sum(1 for line in lines if line.endswith(b"\r\n"))
|
|
723
|
+
lf = sum(1 for line in lines if line.endswith(b"\n") and not line.endswith(b"\r\n"))
|
|
724
|
+
return b"\r\n" if crlf > 0 and crlf >= lf else b"\n"
|
|
725
|
+
|
|
726
|
+
|
|
727
|
+
def _strip_trailing_eol(buf: bytearray) -> None:
|
|
728
|
+
if buf.endswith(b"\n"):
|
|
729
|
+
del buf[-1:]
|
|
730
|
+
if buf.endswith(b"\r"):
|
|
731
|
+
del buf[-1:]
|
|
732
|
+
|
|
733
|
+
|
|
734
|
+
def _splice_hunks_into_base(base: bytes, hunks: Sequence[ComposeHunk]) -> bytes:
|
|
735
|
+
base_lines = _split_lines_keep_eol(base)
|
|
736
|
+
eol = _dominant_eol(base_lines)
|
|
737
|
+
out = bytearray()
|
|
738
|
+
cursor = 0
|
|
739
|
+
for hunk in sorted(hunks, key=lambda item: item.old_start):
|
|
740
|
+
start = max(hunk.old_start - 1, 0)
|
|
741
|
+
while cursor < start and cursor < len(base_lines):
|
|
742
|
+
out.extend(base_lines[cursor])
|
|
743
|
+
cursor += 1
|
|
744
|
+
prev = b""
|
|
745
|
+
for index, line in enumerate(_diff_lines_preserve_cr(hunk.raw_patch)):
|
|
746
|
+
if index == 0:
|
|
747
|
+
continue
|
|
748
|
+
raw = line.encode()
|
|
749
|
+
if raw.startswith(b"\\"):
|
|
750
|
+
if prev in {b"+", b" "}:
|
|
751
|
+
_strip_trailing_eol(out)
|
|
752
|
+
continue
|
|
753
|
+
if raw.startswith(b"-"):
|
|
754
|
+
cursor += 1
|
|
755
|
+
prev = b"-"
|
|
756
|
+
elif raw.startswith(b"+"):
|
|
757
|
+
content = raw[1:]
|
|
758
|
+
if content.endswith(b"\r"):
|
|
759
|
+
content = content[:-1]
|
|
760
|
+
out.extend(content)
|
|
761
|
+
out.extend(eol)
|
|
762
|
+
prev = b"+"
|
|
763
|
+
else:
|
|
764
|
+
if cursor < len(base_lines):
|
|
765
|
+
out.extend(base_lines[cursor])
|
|
766
|
+
cursor += 1
|
|
767
|
+
prev = b" "
|
|
768
|
+
while cursor < len(base_lines):
|
|
769
|
+
out.extend(base_lines[cursor])
|
|
770
|
+
cursor += 1
|
|
771
|
+
return bytes(out)
|
|
772
|
+
|
|
773
|
+
|
|
774
|
+
__all__ = [
|
|
775
|
+
"ComposeGroupPatch",
|
|
776
|
+
"ComposeStageOutcome",
|
|
777
|
+
"SkippedFile",
|
|
778
|
+
"StageResult",
|
|
779
|
+
"build_compose_snapshot",
|
|
780
|
+
"create_executable_group_patch",
|
|
781
|
+
"force_stage_file_from_base_in_index",
|
|
782
|
+
"pin_snapshot_worktree_state",
|
|
783
|
+
"stage_executable_group_in_index",
|
|
784
|
+
]
|