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/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
|