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.
Files changed (54) hide show
  1. lgit/__init__.py +75 -0
  2. lgit/__main__.py +8 -0
  3. lgit/analysis.py +326 -0
  4. lgit/api.py +1077 -0
  5. lgit/cache.py +338 -0
  6. lgit/changelog.py +523 -0
  7. lgit/cli.py +1104 -0
  8. lgit/compose.py +2110 -0
  9. lgit/config.py +437 -0
  10. lgit/diffing.py +384 -0
  11. lgit/errors.py +137 -0
  12. lgit/git.py +852 -0
  13. lgit/map_reduce.py +508 -0
  14. lgit/markdown_output.py +709 -0
  15. lgit/models.py +924 -0
  16. lgit/normalization.py +411 -0
  17. lgit/patch.py +784 -0
  18. lgit/profile.py +426 -0
  19. lgit/py.typed +0 -0
  20. lgit/repo.py +287 -0
  21. lgit/resources/__init__.py +1 -0
  22. lgit/resources/commit_types.json +242 -0
  23. lgit/resources/prompts/analysis/default.md +237 -0
  24. lgit/resources/prompts/analysis/markdown.md +112 -0
  25. lgit/resources/prompts/changelog/default.md +89 -0
  26. lgit/resources/prompts/changelog/markdown.md +60 -0
  27. lgit/resources/prompts/compose-bind/default.md +40 -0
  28. lgit/resources/prompts/compose-bind/markdown.md +41 -0
  29. lgit/resources/prompts/compose-intent/default.md +63 -0
  30. lgit/resources/prompts/compose-intent/markdown.md +59 -0
  31. lgit/resources/prompts/fast/default.md +46 -0
  32. lgit/resources/prompts/fast/markdown.md +51 -0
  33. lgit/resources/prompts/map/default.md +67 -0
  34. lgit/resources/prompts/map/markdown.md +63 -0
  35. lgit/resources/prompts/reduce/default.md +81 -0
  36. lgit/resources/prompts/reduce/markdown.md +68 -0
  37. lgit/resources/prompts/summary/default.md +74 -0
  38. lgit/resources/prompts/summary/markdown.md +77 -0
  39. lgit/resources/validation_data.json +1 -0
  40. lgit/rewrite.py +392 -0
  41. lgit/style.py +295 -0
  42. lgit/templates.py +385 -0
  43. lgit/testing/__init__.py +62 -0
  44. lgit/testing/compare.py +57 -0
  45. lgit/testing/fixture.py +386 -0
  46. lgit/testing/report.py +201 -0
  47. lgit/testing/runner.py +256 -0
  48. lgit/tokens.py +90 -0
  49. lgit/validation.py +545 -0
  50. lgit_cli-3.7.0.dist-info/METADATA +288 -0
  51. lgit_cli-3.7.0.dist-info/RECORD +54 -0
  52. lgit_cli-3.7.0.dist-info/WHEEL +4 -0
  53. lgit_cli-3.7.0.dist-info/entry_points.txt +2 -0
  54. 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
+ ]