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/git.py ADDED
@@ -0,0 +1,852 @@
1
+ """Git subprocess plumbing and snapshot/index helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import stat as stat_module
7
+ import subprocess
8
+ import time
9
+ from collections.abc import Iterable, Mapping, Sequence
10
+ from dataclasses import dataclass
11
+ from datetime import datetime
12
+ from pathlib import Path
13
+
14
+ from .errors import GitError, GitIndexLocked, NoChanges, ValidationFailure
15
+ from .models import CommitMetadata
16
+
17
+ _GIT_BACKGROUND_CONFIG = (
18
+ ("core.fsmonitor", "false"),
19
+ ("core.untrackedCache", "false"),
20
+ )
21
+
22
+ _DISABLE_GIT_BACKGROUND_FEATURES = True
23
+
24
+
25
+ def init_git_command_settings(config: object) -> None:
26
+ """Initialize process-wide git subprocess settings from config."""
27
+
28
+ global _DISABLE_GIT_BACKGROUND_FEATURES
29
+ _DISABLE_GIT_BACKGROUND_FEATURES = bool(getattr(config, "disable_git_background_features", True))
30
+
31
+
32
+ @dataclass(slots=True)
33
+ class GitResult:
34
+ """Captured git subprocess result."""
35
+
36
+ args: tuple[str, ...]
37
+ returncode: int
38
+ stdout: str
39
+ stderr: str
40
+
41
+
42
+ @dataclass(slots=True)
43
+ class GitBytesResult:
44
+ """Captured git subprocess result with raw output bytes."""
45
+
46
+ args: tuple[str, ...]
47
+ returncode: int
48
+ stdout_bytes: bytes
49
+ stderr_bytes: bytes
50
+
51
+ @property
52
+ def stdout(self) -> bytes:
53
+ return self.stdout_bytes
54
+
55
+ @property
56
+ def stderr(self) -> bytes:
57
+ return self.stderr_bytes
58
+
59
+
60
+ @dataclass(slots=True)
61
+ class StylePatterns:
62
+ """Quantified commit-message style patterns from history."""
63
+
64
+ scope_usage_pct: float
65
+ common_verbs: list[tuple[str, int]]
66
+ avg_length: int
67
+ length_range: tuple[int, int]
68
+ lowercase_pct: float
69
+ top_scopes: list[tuple[str, int]]
70
+
71
+ def format_for_prompt(self) -> str:
72
+ """Format style patterns for prompt injection."""
73
+
74
+ lines = [f"Scope usage: {self.scope_usage_pct:.0f}% of commits use scopes"]
75
+ if self.common_verbs:
76
+ verbs = ", ".join(f"{verb} ({count})" for verb, count in self.common_verbs[:5])
77
+ lines.append(f"Common verbs: {verbs}")
78
+ lines.append(f"Average length: {self.avg_length} chars (range: {self.length_range[0]}-{self.length_range[1]})")
79
+ lines.append(f"Capitalization: {self.lowercase_pct:.0f}% start lowercase")
80
+ if self.top_scopes:
81
+ scopes = ", ".join(f"{scope} ({count})" for scope, count in self.top_scopes[:5])
82
+ lines.append(f"Top scopes: {scopes}")
83
+ return "\n".join(lines)
84
+
85
+
86
+ def git_command_env(
87
+ extra: Mapping[str, str | os.PathLike[str]] | None = None,
88
+ *,
89
+ index_file: str | os.PathLike[str] | None = None,
90
+ disable_background_features: bool | None = None,
91
+ ) -> dict[str, str]:
92
+ """Return an environment for git with optional temp index and background disables."""
93
+
94
+ env = os.environ.copy()
95
+ if extra:
96
+ env.update({key: os.fspath(value) for key, value in extra.items()})
97
+ if index_file is not None:
98
+ env["GIT_INDEX_FILE"] = os.fspath(index_file)
99
+ if disable_background_features is None:
100
+ disable_background_features = _DISABLE_GIT_BACKGROUND_FEATURES
101
+ if disable_background_features:
102
+ try:
103
+ offset = int(env.get("GIT_CONFIG_COUNT", "0"))
104
+ except ValueError:
105
+ offset = 0
106
+ for idx, (key, value) in enumerate(_GIT_BACKGROUND_CONFIG, start=offset):
107
+ env[f"GIT_CONFIG_KEY_{idx}"] = key
108
+ env[f"GIT_CONFIG_VALUE_{idx}"] = value
109
+ env["GIT_CONFIG_COUNT"] = str(offset + len(_GIT_BACKGROUND_CONFIG))
110
+ return env
111
+
112
+
113
+ def _git_argv(args: Sequence[str | os.PathLike[str]]) -> tuple[str, ...]:
114
+ return ("git", *(os.fspath(arg) for arg in args))
115
+
116
+
117
+ def _run_git_process(
118
+ args: Sequence[str | os.PathLike[str]],
119
+ *,
120
+ cwd: str | os.PathLike[str],
121
+ input_data: str | bytes | None,
122
+ text: bool,
123
+ env: Mapping[str, str | os.PathLike[str]] | None,
124
+ index_file: str | os.PathLike[str] | None,
125
+ disable_background_features: bool | None,
126
+ ) -> tuple[tuple[str, ...], subprocess.CompletedProcess]:
127
+ argv = _git_argv(args)
128
+ run_kwargs: dict[str, object] = {
129
+ "cwd": os.fspath(cwd),
130
+ "input": input_data,
131
+ "stdout": subprocess.PIPE,
132
+ "stderr": subprocess.PIPE,
133
+ "env": git_command_env(
134
+ env,
135
+ index_file=index_file,
136
+ disable_background_features=disable_background_features,
137
+ ),
138
+ "shell": False,
139
+ "check": False,
140
+ }
141
+ if text:
142
+ run_kwargs.update({"text": True, "encoding": "utf-8", "errors": "replace"})
143
+ return argv, subprocess.run(argv, **run_kwargs)
144
+
145
+
146
+ def _raise_git_error(
147
+ args: Sequence[str | os.PathLike[str]],
148
+ cwd: str | os.PathLike[str],
149
+ stdout: str,
150
+ stderr: str,
151
+ ) -> None:
152
+ locked = _index_lock_error(stderr, cwd)
153
+ if locked is not None:
154
+ raise locked
155
+ detail = f"{stderr.strip()}\n{stdout.strip()}".strip()
156
+ raise GitError(f"git {' '.join(os.fspath(arg) for arg in args)} failed: {detail}")
157
+
158
+
159
+ def run_git(
160
+ args: Sequence[str | os.PathLike[str]],
161
+ *,
162
+ cwd: str | os.PathLike[str] = ".",
163
+ input_text: str | None = None,
164
+ check: bool = True,
165
+ allow_exit_codes: Iterable[int] = (),
166
+ env: Mapping[str, str | os.PathLike[str]] | None = None,
167
+ index_file: str | os.PathLike[str] | None = None,
168
+ disable_background_features: bool | None = None,
169
+ ) -> GitResult:
170
+ """Run git with explicit argv and return captured UTF-8 text output."""
171
+
172
+ argv, completed = _run_git_process(
173
+ args,
174
+ cwd=cwd,
175
+ input_data=input_text,
176
+ text=True,
177
+ env=env,
178
+ index_file=index_file,
179
+ disable_background_features=disable_background_features,
180
+ )
181
+ result = GitResult(argv, completed.returncode, completed.stdout, completed.stderr)
182
+ allowed = set(allow_exit_codes)
183
+ if check and completed.returncode != 0 and completed.returncode not in allowed:
184
+ _raise_git_error(args, cwd, completed.stdout, completed.stderr)
185
+ return result
186
+
187
+
188
+ def run_git_bytes(
189
+ args: Sequence[str | os.PathLike[str]],
190
+ *,
191
+ cwd: str | os.PathLike[str] = ".",
192
+ input_bytes: bytes | None = None,
193
+ check: bool = True,
194
+ allow_exit_codes: Iterable[int] = (),
195
+ env: Mapping[str, str | os.PathLike[str]] | None = None,
196
+ index_file: str | os.PathLike[str] | None = None,
197
+ disable_background_features: bool | None = None,
198
+ ) -> GitBytesResult:
199
+ """Run git and preserve stdout as raw bytes."""
200
+
201
+ argv, completed = _run_git_process(
202
+ args,
203
+ cwd=cwd,
204
+ input_data=input_bytes,
205
+ text=False,
206
+ env=env,
207
+ index_file=index_file,
208
+ disable_background_features=disable_background_features,
209
+ )
210
+ result = GitBytesResult(argv, completed.returncode, completed.stdout, completed.stderr)
211
+ allowed = set(allow_exit_codes)
212
+ if check and completed.returncode != 0 and completed.returncode not in allowed:
213
+ stderr = completed.stderr.decode("utf-8", errors="replace")
214
+ stdout = completed.stdout.decode("utf-8", errors="replace")
215
+ _raise_git_error(args, cwd, stdout, stderr)
216
+ return result
217
+
218
+
219
+ class TempGitIndex:
220
+ """Temporary Git index under `.git/llm-git`, removed on context exit."""
221
+
222
+ def __init__(self, dir: str | os.PathLike[str] = ".") -> None:
223
+ temp_dir = get_git_dir(dir) / "llm-git"
224
+ temp_dir.mkdir(parents=True, exist_ok=True)
225
+ pid = os.getpid()
226
+ nanos = time.time_ns()
227
+ for attempt in range(100):
228
+ path = temp_dir / f"index-{pid}-{nanos}-{attempt}"
229
+ try:
230
+ fd = os.open(path, os.O_CREAT | os.O_EXCL | os.O_WRONLY)
231
+ except FileExistsError:
232
+ continue
233
+ except OSError as exc:
234
+ raise GitError(f"Failed to create temporary git index: {exc}") from exc
235
+ else:
236
+ os.close(fd)
237
+ path.unlink(missing_ok=True)
238
+ self.path = path
239
+ return
240
+ raise GitError("Failed to allocate unique temporary git index path")
241
+
242
+ def __fspath__(self) -> str:
243
+ return os.fspath(self.path)
244
+
245
+ def __enter__(self) -> TempGitIndex:
246
+ return self
247
+
248
+ def __exit__(self, exc_type: object, exc: object, tb: object) -> None:
249
+ self.cleanup()
250
+
251
+ def cleanup(self) -> None:
252
+ """Remove the temp index and a sibling lock if either exists."""
253
+
254
+ self.path.unlink(missing_ok=True)
255
+ self.path.with_suffix(self.path.suffix + ".lock").unlink(missing_ok=True)
256
+
257
+
258
+ def ensure_git_repo(dir: str | os.PathLike[str] = ".") -> None:
259
+ """Raise unless `dir` is inside a git work tree."""
260
+
261
+ result = run_git(["rev-parse", "--show-toplevel"], cwd=dir, check=False)
262
+ if result.returncode == 0:
263
+ return
264
+ if "not a git repository" in result.stderr:
265
+ raise GitError("Not a git repository (or any of the parent directories): .git")
266
+ raise GitError(f"Failed to detect git repository: {result.stderr.strip()}")
267
+
268
+
269
+ def get_git_dir(dir: str | os.PathLike[str] = ".") -> Path:
270
+ """Return the absolute git directory for `dir`."""
271
+
272
+ result = run_git(["rev-parse", "--absolute-git-dir"], cwd=dir)
273
+ return Path(result.stdout.strip())
274
+
275
+
276
+ def get_git_diff(
277
+ mode: object,
278
+ target: str | None = None,
279
+ dir: str | os.PathLike[str] = ".",
280
+ config: object | None = None,
281
+ ) -> str:
282
+ """Return a diff for staged, unstaged, or commit mode."""
283
+
284
+ mode_name = _mode_name(mode)
285
+ max_len = int(getattr(config, "max_diff_length", 200_000))
286
+ exclude_old_message = bool(getattr(config, "exclude_old_message", False))
287
+
288
+ if mode_name == "staged":
289
+ diff = _diff_with_retry(["diff", "--cached"], dir, max_len)
290
+ elif mode_name == "commit":
291
+ if target is None:
292
+ raise ValidationFailure("--target required for commit mode")
293
+ args = ["show"]
294
+ if exclude_old_message:
295
+ args.append("--format=")
296
+ args.append(target)
297
+ diff = _diff_with_retry(args, dir, max_len, insert_u1_before=target)
298
+ elif mode_name == "unstaged":
299
+ diff = _diff_with_retry(["diff"], dir, max_len)
300
+ diff = _append_untracked_diff(diff, dir, _list_untracked_files(dir))
301
+ elif mode_name == "compose":
302
+ raise GitError("compose mode diff is handled by get_compose_diff")
303
+ else:
304
+ raise ValidationFailure(f"unknown mode: {mode!r}")
305
+
306
+ if not diff.strip():
307
+ raise NoChanges(mode_name)
308
+ return diff
309
+
310
+
311
+ def get_git_stat(
312
+ mode: object,
313
+ target: str | None = None,
314
+ dir: str | os.PathLike[str] = ".",
315
+ config: object | None = None,
316
+ ) -> str:
317
+ """Return git diff --stat or git show --stat output for a mode."""
318
+
319
+ mode_name = _mode_name(mode)
320
+ exclude_old_message = bool(getattr(config, "exclude_old_message", False))
321
+ if mode_name == "staged":
322
+ return run_git(["diff", "--cached", "--stat"], cwd=dir).stdout
323
+ if mode_name == "commit":
324
+ if target is None:
325
+ raise ValidationFailure("--target required for commit mode")
326
+ args = ["show"]
327
+ if exclude_old_message:
328
+ args.append("--format=")
329
+ args.extend(["--stat", target])
330
+ return run_git(args, cwd=dir).stdout
331
+ if mode_name == "unstaged":
332
+ stat = run_git(["diff", "--stat"], cwd=dir).stdout
333
+ return _append_untracked_stat(stat, dir, _list_untracked_files(dir))
334
+ if mode_name == "compose":
335
+ raise GitError("compose mode stat is handled by get_compose_stat")
336
+ raise ValidationFailure(f"unknown mode: {mode!r}")
337
+
338
+
339
+ def get_git_numstat(
340
+ mode: object,
341
+ target: str | None = None,
342
+ dir: str | os.PathLike[str] = ".",
343
+ config: object | None = None,
344
+ ) -> str:
345
+ """Return git diff --numstat or git show --numstat output for a mode."""
346
+
347
+ mode_name = _mode_name(mode)
348
+ exclude_old_message = bool(getattr(config, "exclude_old_message", False))
349
+ if mode_name == "staged":
350
+ return run_git(["diff", "--cached", "--numstat"], cwd=dir).stdout
351
+ if mode_name == "commit":
352
+ if target is None:
353
+ raise ValidationFailure("--target required for commit mode")
354
+ args = ["show"]
355
+ if exclude_old_message:
356
+ args.append("--format=")
357
+ args.extend(["--numstat", target])
358
+ return run_git(args, cwd=dir).stdout
359
+ if mode_name == "unstaged":
360
+ numstat = run_git(["diff", "--numstat"], cwd=dir).stdout
361
+ return _append_untracked_numstat(numstat, dir, _list_untracked_files(dir))
362
+ if mode_name == "compose":
363
+ raise GitError("compose mode numstat is handled by get_compose_numstat")
364
+ raise ValidationFailure(f"unknown mode: {mode!r}")
365
+
366
+
367
+ def get_compose_diff(dir: str | os.PathLike[str] = ".", config: object | None = None) -> str:
368
+ """Return the compose-mode diff against HEAD, including untracked files."""
369
+
370
+ max_len = int(getattr(config, "max_diff_length", 200_000))
371
+ args = [
372
+ "diff",
373
+ "--no-ext-diff",
374
+ "--no-textconv",
375
+ "--no-color",
376
+ "--src-prefix=a/",
377
+ "--dst-prefix=b/",
378
+ "HEAD",
379
+ ]
380
+ diff = _diff_with_retry(args, dir, max_len, insert_u1_before="HEAD")
381
+ diff = _append_untracked_diff(diff, dir, _list_untracked_files(dir))
382
+ if not diff.strip():
383
+ raise NoChanges("compose")
384
+ return diff
385
+
386
+
387
+ def get_compose_diff_with_config(dir: str | os.PathLike[str] = ".", config: object | None = None) -> str:
388
+ """Compatibility-free named entry point for compose diff with config."""
389
+
390
+ return get_compose_diff(dir, config)
391
+
392
+
393
+ def get_compose_stat(dir: str | os.PathLike[str] = ".") -> str:
394
+ """Return compose-mode --stat output against HEAD, including untracked files."""
395
+
396
+ stat = run_git(["diff", "--no-ext-diff", "--no-textconv", "--no-color", "HEAD", "--stat"], cwd=dir).stdout
397
+ stat = _append_untracked_stat(stat, dir, _list_untracked_files(dir))
398
+ if not stat.strip():
399
+ raise NoChanges("compose")
400
+ return stat
401
+
402
+
403
+ def get_compose_numstat(dir: str | os.PathLike[str] = ".") -> str:
404
+ """Return compose-mode --numstat output against HEAD, including untracked files."""
405
+
406
+ numstat = run_git(["diff", "--no-ext-diff", "--no-textconv", "--no-color", "HEAD", "--numstat"], cwd=dir).stdout
407
+ numstat = _append_untracked_numstat(numstat, dir, _list_untracked_files(dir))
408
+ if not numstat.strip():
409
+ raise NoChanges("compose")
410
+ return numstat
411
+
412
+
413
+ def write_real_index_tree(dir: str | os.PathLike[str] = ".") -> str:
414
+ """Write the live index to a tree and return its oid."""
415
+
416
+ return run_git(["write-tree"], cwd=dir).stdout.strip()
417
+
418
+
419
+ def index_matches_tree(tree: str, dir: str | os.PathLike[str] = ".") -> bool:
420
+ """Return true when the live index currently writes to `tree`."""
421
+
422
+ return write_real_index_tree(dir) == tree
423
+
424
+
425
+ def index_drifted_from(tree: str, dir: str | os.PathLike[str] = ".") -> bool:
426
+ """Return true when the live index no longer matches a captured tree."""
427
+
428
+ return not index_matches_tree(tree, dir)
429
+
430
+
431
+ def read_tree_into_index(index_file: str | os.PathLike[str], treeish: str, dir: str | os.PathLike[str] = ".") -> None:
432
+ """Populate a temporary index with `treeish`."""
433
+
434
+ run_git(["read-tree", treeish], cwd=dir, index_file=index_file)
435
+
436
+
437
+ def write_index_tree(index_file: str | os.PathLike[str], dir: str | os.PathLike[str] = ".") -> str:
438
+ """Write a temporary index to a tree and return its oid."""
439
+
440
+ return run_git(["write-tree"], cwd=dir, index_file=index_file).stdout.strip()
441
+
442
+
443
+ def get_head_hash(dir: str | os.PathLike[str] = ".") -> str:
444
+ """Return HEAD's commit oid."""
445
+
446
+ return run_git(["rev-parse", "HEAD"], cwd=dir).stdout.strip()
447
+
448
+
449
+ def current_head_ref(dir: str | os.PathLike[str] = ".") -> str:
450
+ """Return the symbolic HEAD ref, or HEAD for detached/unborn state."""
451
+
452
+ result = run_git(["symbolic-ref", "-q", "HEAD"], cwd=dir, check=False)
453
+ refname = result.stdout.strip()
454
+ return refname if result.returncode == 0 and refname else "HEAD"
455
+
456
+
457
+ def commit_snapshot_tree(
458
+ message: str,
459
+ tree: str,
460
+ dir: str | os.PathLike[str] = ".",
461
+ *,
462
+ sign: bool = False,
463
+ signoff: bool = False,
464
+ amend: bool = False,
465
+ ) -> str | None:
466
+ """Commit a captured tree without touching the live index or worktree."""
467
+
468
+ final_message = append_signoff_trailer(message, dir) if signoff else message
469
+ try:
470
+ head = get_head_hash(dir)
471
+ except GitError:
472
+ head = None
473
+ head_ref = current_head_ref(dir)
474
+
475
+ parents: list[str] = []
476
+ if head is not None:
477
+ if amend:
478
+ parents = _rev_parse_parents(head, dir)
479
+ else:
480
+ if _rev_parse_tree_of(head, dir) == tree:
481
+ return None
482
+ parents.append(head)
483
+
484
+ new_hash = commit_tree(tree, parents, final_message, dir, sign=sign)
485
+ update_ref_checked(head_ref, new_hash, head or "", dir)
486
+ return new_hash
487
+
488
+
489
+ def commit_tree(
490
+ tree: str,
491
+ parents: Sequence[str] = (),
492
+ message: str = "",
493
+ dir: str | os.PathLike[str] = ".",
494
+ *,
495
+ sign: bool = False,
496
+ env: Mapping[str, str | os.PathLike[str]] | None = None,
497
+ ) -> str:
498
+ """Create a commit object for `tree` and return its oid."""
499
+
500
+ args = ["commit-tree"]
501
+ if sign:
502
+ args.append("-S")
503
+ args.append(tree)
504
+ for parent in parents:
505
+ args.extend(["-p", parent])
506
+ args.extend(["-F", "-"])
507
+ result = run_git(args, cwd=dir, input_text=message, env=env)
508
+ commit_hash = result.stdout.strip()
509
+ if not commit_hash:
510
+ raise GitError("git commit-tree returned an empty hash")
511
+ return commit_hash
512
+
513
+
514
+ def update_ref_checked(refname: str, new: str, old: str, dir: str | os.PathLike[str] = ".") -> None:
515
+ """Atomically update a ref, verifying the old value Git sees."""
516
+
517
+ run_git(["update-ref", refname, new, old], cwd=dir)
518
+
519
+
520
+ def reset_mixed_to(treeish: str, dir: str | os.PathLike[str] = ".") -> None:
521
+ """Reset the live index to `treeish` without changing the worktree."""
522
+
523
+ run_git(["reset", "--mixed", "-q", treeish], cwd=dir)
524
+
525
+
526
+ def reset_paths_to(treeish: str, paths: Sequence[str], dir: str | os.PathLike[str] = ".") -> None:
527
+ """Reset selected index paths to `treeish`, leaving worktree untouched."""
528
+
529
+ if not paths:
530
+ return
531
+ run_git(["reset", "-q", treeish, "--", *paths], cwd=dir)
532
+
533
+
534
+ def append_signoff_trailer(message: str, dir: str | os.PathLike[str] = ".") -> str:
535
+ """Append a Signed-off-by trailer from Git's committer identity."""
536
+
537
+ ident = run_git(["var", "GIT_COMMITTER_IDENT"], cwd=dir).stdout
538
+ end = ident.find(">")
539
+ if end == -1:
540
+ raise GitError(f"Could not parse committer identity: {ident.strip()}")
541
+ signer = ident[: end + 1].strip()
542
+ return f"{message.rstrip()}\n\nSigned-off-by: {signer}"
543
+
544
+
545
+ def get_commit_list(start_ref: str | None = None, dir: str | os.PathLike[str] = ".") -> list[str]:
546
+ """Return commit hashes to rewrite in chronological order."""
547
+
548
+ target = f"{start_ref}..HEAD" if start_ref else "HEAD"
549
+ stdout = run_git(["rev-list", "--reverse", target], cwd=dir).stdout
550
+ return [line for line in stdout.splitlines() if line]
551
+
552
+
553
+ def get_commit_metadata(hash: str, dir: str | os.PathLike[str] = "."):
554
+ """Extract author, committer, message, parent, and tree metadata for a commit."""
555
+
556
+ fmt = "%an%x00%ae%x00%aI%x00%cn%x00%ce%x00%cI%x00%B"
557
+ info = run_git(["show", "-s", f"--format={fmt}", hash], cwd=dir).stdout
558
+ parts = info.split("\0", 6)
559
+ if len(parts) < 7:
560
+ raise GitError(f"Failed to parse commit metadata for {hash}")
561
+ tree_hash = _rev_parse_tree_of(hash, dir)
562
+ parents_line = run_git(["rev-list", "--parents", "-n", "1", hash], cwd=dir).stdout
563
+ parent_hashes = parents_line.split()[1:]
564
+ return CommitMetadata(
565
+ hash=hash,
566
+ author_name=parts[0],
567
+ author_email=parts[1],
568
+ author_date=parts[2],
569
+ committer_name=parts[3],
570
+ committer_email=parts[4],
571
+ committer_date=parts[5],
572
+ message=parts[6].strip(),
573
+ parents=tuple(parent_hashes),
574
+ tree_hash=tree_hash,
575
+ )
576
+
577
+
578
+ def check_working_tree_clean(dir: str | os.PathLike[str] = ".") -> bool:
579
+ """Return true if git status --porcelain is empty."""
580
+
581
+ return run_git(["status", "--porcelain"], cwd=dir).stdout == ""
582
+
583
+
584
+ def create_backup_branch(dir: str | os.PathLike[str] = ".") -> str:
585
+ """Create a timestamped backup branch at the current HEAD and return its name."""
586
+
587
+ branch_name = f"backup-rewrite-{datetime.now().strftime('%Y%m%d-%H%M%S')}"
588
+ run_git(["branch", branch_name, "HEAD"], cwd=dir)
589
+ return branch_name
590
+
591
+
592
+ def get_recent_commits(dir: str | os.PathLike[str] = ".", count: int = 10) -> list[str]:
593
+ """Return recent commit subjects."""
594
+
595
+ return run_git(["log", f"-{count}", "--pretty=format:%s"], cwd=dir).stdout.splitlines()
596
+
597
+
598
+ def get_common_scopes(dir: str | os.PathLike[str] = ".", limit: int = 100) -> list[tuple[str, int]]:
599
+ """Extract common conventional-commit scopes from history."""
600
+
601
+ counts: dict[str, int] = {}
602
+ for line in run_git(["log", f"-{limit}", "--pretty=format:%s"], cwd=dir).stdout.splitlines():
603
+ scope = _extract_scope_from_commit(line)
604
+ if scope:
605
+ counts[scope] = counts.get(scope, 0) + 1
606
+ return sorted(counts.items(), key=lambda item: item[1], reverse=True)
607
+
608
+
609
+ def extract_style_patterns(commits: Sequence[str]) -> StylePatterns | None:
610
+ """Extract style conventions from commit subjects."""
611
+
612
+ if not commits:
613
+ return None
614
+ scope_count = 0
615
+ lowercase_count = 0
616
+ verb_counts: dict[str, int] = {}
617
+ scope_counts: dict[str, int] = {}
618
+ lengths: list[int] = []
619
+
620
+ for commit in commits:
621
+ if ":" not in commit:
622
+ continue
623
+ prefix, summary = commit.split(":", 1)
624
+ summary = summary.strip()
625
+ scope = _extract_scope_from_prefix(prefix)
626
+ if scope:
627
+ scope_count += 1
628
+ scope_counts[scope] = scope_counts.get(scope, 0) + 1
629
+ if summary[:1].islower():
630
+ lowercase_count += 1
631
+ words = summary.split()
632
+ if words:
633
+ verb = words[0].lower()
634
+ verb_counts[verb] = verb_counts.get(verb, 0) + 1
635
+ lengths.append(len(summary))
636
+
637
+ total = len(commits)
638
+ avg_length = sum(lengths) // len(lengths) if lengths else 0
639
+ length_range = (min(lengths), max(lengths)) if lengths else (0, 0)
640
+ return StylePatterns(
641
+ scope_usage_pct=scope_count / total * 100,
642
+ common_verbs=sorted(verb_counts.items(), key=lambda item: item[1], reverse=True),
643
+ avg_length=avg_length,
644
+ length_range=length_range,
645
+ lowercase_pct=lowercase_count / total * 100,
646
+ top_scopes=sorted(scope_counts.items(), key=lambda item: item[1], reverse=True),
647
+ )
648
+
649
+
650
+ def rewrite_history(
651
+ commits: Sequence[CommitMetadata],
652
+ new_messages: Sequence[str],
653
+ dir: str | os.PathLike[str] = ".",
654
+ ) -> None:
655
+ """Rewrite commits with new messages while preserving metadata."""
656
+
657
+ if len(commits) != len(new_messages):
658
+ raise ValidationFailure("Commit count mismatch")
659
+ current_branch = run_git(["rev-parse", "--abbrev-ref", "HEAD"], cwd=dir).stdout.strip()
660
+ old_head = get_head_hash(dir)
661
+ parent_map: dict[str, str] = {}
662
+ new_head: str | None = None
663
+
664
+ for commit, new_message in zip(commits, new_messages, strict=True):
665
+ old_hash = commit.hash
666
+ new_parents = [parent_map.get(parent, parent) for parent in commit.parents]
667
+ env = {
668
+ "GIT_AUTHOR_NAME": commit.author_name,
669
+ "GIT_AUTHOR_EMAIL": commit.author_email,
670
+ "GIT_AUTHOR_DATE": commit.author_date,
671
+ "GIT_COMMITTER_NAME": commit.committer_name,
672
+ "GIT_COMMITTER_EMAIL": commit.committer_email,
673
+ "GIT_COMMITTER_DATE": commit.committer_date,
674
+ }
675
+ new_hash = commit_tree(commit.tree_hash, new_parents, new_message, dir, env=env)
676
+ parent_map[old_hash] = new_hash
677
+ new_head = new_hash
678
+
679
+ if new_head is not None:
680
+ refname = "HEAD" if current_branch == "HEAD" else f"refs/heads/{current_branch}"
681
+ update_ref_checked(refname, new_head, old_head, dir)
682
+ run_git(["reset", "--hard", new_head], cwd=dir)
683
+
684
+
685
+ def _diff_with_retry(
686
+ args: list[str],
687
+ dir: str | os.PathLike[str],
688
+ max_len: int,
689
+ *,
690
+ insert_u1_before: str | None = None,
691
+ ) -> str:
692
+ result = run_git(args, cwd=dir)
693
+ if len(result.stdout.encode()) <= max_len:
694
+ return result.stdout
695
+ retry_args = args.copy()
696
+ if insert_u1_before is not None and insert_u1_before in retry_args:
697
+ retry_args.insert(retry_args.index(insert_u1_before), "-U1")
698
+ else:
699
+ retry_args.append("-U1")
700
+ return run_git(retry_args, cwd=dir).stdout
701
+
702
+
703
+ def _list_untracked_files(dir: str | os.PathLike[str]) -> list[str]:
704
+ stdout = run_git(["ls-files", "--others", "--exclude-standard"], cwd=dir).stdout
705
+ return [line for line in stdout.splitlines() if line]
706
+
707
+
708
+ def _append_untracked_diff(base_diff: str, dir: str | os.PathLike[str], files: Sequence[str]) -> str:
709
+ diff = base_diff
710
+ for file in files:
711
+ result = run_git(
712
+ [
713
+ "diff",
714
+ "--no-index",
715
+ "--no-ext-diff",
716
+ "--no-textconv",
717
+ "--no-color",
718
+ "--src-prefix=a/",
719
+ "--dst-prefix=b/",
720
+ os.devnull,
721
+ file,
722
+ ],
723
+ cwd=dir,
724
+ check=True,
725
+ allow_exit_codes={1},
726
+ )
727
+ lines = list(_diff_lines_preserve_cr(result.stdout))
728
+ if not lines:
729
+ continue
730
+ mode = next(
731
+ (line.removeprefix("new file mode ") for line in lines if line.startswith("new file mode ")), "100644"
732
+ )
733
+ if diff:
734
+ diff += "\n"
735
+ diff += f"diff --git a/{file} b/{file}\n"
736
+ diff += f"new file mode {mode}\n"
737
+ diff += "index 0000000..0000000\n"
738
+ diff += "--- /dev/null\n"
739
+ diff += f"+++ b/{file}\n"
740
+ for line in _content_diff_lines(lines):
741
+ diff += f"{line}\n"
742
+ return diff
743
+
744
+
745
+ def _append_untracked_stat(stat: str, dir: str | os.PathLike[str], files: Sequence[str]) -> str:
746
+ output = stat
747
+ root = Path(dir)
748
+ for file in files:
749
+ path = root / file
750
+ try:
751
+ metadata = path.stat()
752
+ except OSError:
753
+ continue
754
+ lines = 0
755
+ if stat_module.S_ISREG(metadata.st_mode):
756
+ try:
757
+ lines = len(path.read_text(encoding="utf-8", errors="replace").splitlines())
758
+ except OSError:
759
+ lines = 0
760
+ if output and not output.endswith("\n"):
761
+ output += "\n"
762
+ output += f" {file} | {lines} {'+' * min(lines, 50)}\n"
763
+ return output
764
+
765
+
766
+ def _append_untracked_numstat(numstat: str, dir: str | os.PathLike[str], files: Sequence[str]) -> str:
767
+ output = numstat
768
+ root = Path(dir)
769
+ for file in files:
770
+ path = root / file
771
+ try:
772
+ metadata = path.stat()
773
+ except OSError:
774
+ continue
775
+ if stat_module.S_ISREG(metadata.st_mode):
776
+ try:
777
+ content = path.read_bytes()
778
+ except OSError:
779
+ continue
780
+ if b"\0" in content:
781
+ line = f"-\t-\t{file}"
782
+ else:
783
+ line_count = content.decode("utf-8", errors="replace").count("\n")
784
+ line = f"{line_count}\t0\t{file}"
785
+ else:
786
+ line = f"0\t0\t{file}"
787
+ if output and not output.endswith("\n"):
788
+ output += "\n"
789
+ output += line + "\n"
790
+ return output
791
+
792
+
793
+ def _diff_lines_preserve_cr(input: str) -> Iterable[str]:
794
+ """Split lines while only stripping the final LF, preserving bare CR bytes."""
795
+ for line in input.splitlines(keepends=True):
796
+ yield line[:-1] if line.endswith("\n") else line
797
+
798
+
799
+ def _content_diff_lines(lines: Sequence[str]) -> list[str]:
800
+ for index, line in enumerate(lines):
801
+ if line.startswith("@@") or line.startswith("Binary files "):
802
+ return list(lines[index:])
803
+ return []
804
+
805
+
806
+ def _rev_parse_tree_of(commitish: str, dir: str | os.PathLike[str]) -> str:
807
+ return run_git(["rev-parse", f"{commitish}^{{tree}}"], cwd=dir).stdout.strip()
808
+
809
+
810
+ def _rev_parse_parents(commitish: str, dir: str | os.PathLike[str]) -> list[str]:
811
+ return run_git(["rev-parse", f"{commitish}^@"], cwd=dir).stdout.splitlines()
812
+
813
+
814
+ def _index_lock_error(stderr: str, dir: str | os.PathLike[str]) -> GitIndexLocked | None:
815
+ if "index.lock" not in stderr:
816
+ return None
817
+ for line in stderr.splitlines():
818
+ start = line.find("'")
819
+ if start == -1:
820
+ continue
821
+ end = line.find("'", start + 1)
822
+ if end == -1:
823
+ continue
824
+ candidate = line[start + 1 : end]
825
+ if candidate.endswith("index.lock"):
826
+ return GitIndexLocked(candidate)
827
+ return GitIndexLocked(Path(dir) / ".git" / "index.lock")
828
+
829
+
830
+ def _mode_name(mode: object) -> str:
831
+ if isinstance(mode, str):
832
+ return mode.lower()
833
+ value = getattr(mode, "value", None)
834
+ if isinstance(value, str):
835
+ return value.lower()
836
+ name = getattr(mode, "name", None)
837
+ if isinstance(name, str):
838
+ return name.lower()
839
+ return str(mode).lower()
840
+
841
+
842
+ def _extract_scope_from_commit(commit_msg: str) -> str | None:
843
+ prefix, sep, _ = commit_msg.partition(":")
844
+ return _extract_scope_from_prefix(prefix) if sep else None
845
+
846
+
847
+ def _extract_scope_from_prefix(prefix: str) -> str | None:
848
+ start = prefix.find("(")
849
+ end = prefix.find(")", start + 1)
850
+ if start != -1 and end != -1 and start < end:
851
+ return prefix[start + 1 : end]
852
+ return None