claude-dev-env 2.35.0 → 2.36.0
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.
- package/.agents/skills/autoconverge/SKILL.md +19 -15
- package/.agents/skills/autoconverge/reference/gotchas.md +3 -3
- package/.agents/skills/autoconverge/reference/multi-pr.md +6 -2
- package/.agents/skills/autoconverge/workflow/converge.contract.test.mjs +11 -0
- package/.agents/skills/autoconverge/workflow/converge.mjs +26 -33
- package/.agents/skills/autoconverge/workflow/converge.run-input.test.mjs +26 -2
- package/.agents/skills/autoconverge/workflow/converge_multi.mjs +12 -28
- package/.agents/skills/autoconverge/workflow/converge_multi.run-input.test.mjs +16 -0
- package/bin/cde.mjs +175 -0
- package/bin/cde.test.mjs +222 -0
- package/bin/install-constants.mjs +4 -0
- package/bin/install.cursor-rules.test.mjs +233 -2
- package/bin/install.mjs +49 -3
- package/bin/pstack-model-preferences.codex.json +23 -0
- package/bin/select-pstack-models.test.mjs +378 -0
- package/hooks/hooks_constants/tool_names.py +20 -0
- package/hooks/validators/fast_save_validators.py +63 -89
- package/hooks/validators/run_all_validators.py +16 -8
- package/hooks/validators/test_fast_save_validators_policy_lint.py +25 -0
- package/hooks/validators/test_run_all_validators_policy_lint.py +52 -0
- package/package.json +3 -2
- package/rules/pstack-models.md +119 -0
- package/scripts/cde_lint.py +258 -0
- package/scripts/policy_lint/__init__.py +41 -0
- package/scripts/policy_lint/adapter_configuration.py +91 -0
- package/scripts/policy_lint/adapter_detectors.py +328 -0
- package/scripts/policy_lint/adapter_pairing.py +145 -0
- package/scripts/policy_lint/adapter_support.py +97 -0
- package/scripts/policy_lint/adapters.py +352 -0
- package/scripts/policy_lint/config/__init__.py +1 -0
- package/scripts/policy_lint/config/constants.py +66 -0
- package/scripts/policy_lint/engine.py +224 -0
- package/scripts/policy_lint/model.py +387 -0
- package/scripts/policy_lint/registry.py +162 -0
- package/scripts/policy_lint/render.py +91 -0
- package/scripts/policy_lint/selection.py +372 -0
- package/scripts/policy_lint/selection_git.py +147 -0
- package/scripts/select_pstack_models.mjs +304 -0
- package/scripts/tests/test_cde_lint.py +374 -0
- package/scripts/tests/test_policy_lint_rules_engine.py +381 -0
- package/scripts/tests/test_policy_lint_rules_registry.py +252 -0
- package/scripts/tests/test_policy_lint_selection.py +373 -0
|
@@ -0,0 +1,372 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import difflib
|
|
4
|
+
import os
|
|
5
|
+
from pathlib import Path, PurePosixPath
|
|
6
|
+
|
|
7
|
+
from .config import constants
|
|
8
|
+
from .model import (
|
|
9
|
+
BaseChanges,
|
|
10
|
+
ContentOrigin,
|
|
11
|
+
Document,
|
|
12
|
+
DocumentSet,
|
|
13
|
+
ExplicitFiles,
|
|
14
|
+
LintRequest,
|
|
15
|
+
RepositoryTree,
|
|
16
|
+
SelectionKind,
|
|
17
|
+
StagedChanges,
|
|
18
|
+
TextDocument,
|
|
19
|
+
)
|
|
20
|
+
from .selection_git import (
|
|
21
|
+
GitSelectionError,
|
|
22
|
+
git_bytes_for,
|
|
23
|
+
head_revision,
|
|
24
|
+
read_blob,
|
|
25
|
+
split_nul_tokens,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class SelectionRunFatal(ValueError):
|
|
30
|
+
"""Raised when a source selection cannot be resolved safely."""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
SelectionError = SelectionRunFatal
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def select_documents(request: LintRequest) -> DocumentSet:
|
|
37
|
+
"""Resolve one request into documents.
|
|
38
|
+
|
|
39
|
+
Args:
|
|
40
|
+
request: Source selection and repository root.
|
|
41
|
+
|
|
42
|
+
Returns:
|
|
43
|
+
The selected document set.
|
|
44
|
+
|
|
45
|
+
Raises:
|
|
46
|
+
SelectionRunFatal: If the source cannot be read safely.
|
|
47
|
+
"""
|
|
48
|
+
try:
|
|
49
|
+
repository_root = _validated_repository_root(request.repository_root)
|
|
50
|
+
return _select_source(repository_root, request.source)
|
|
51
|
+
except (GitSelectionError, OSError) as error:
|
|
52
|
+
raise SelectionRunFatal(str(error)) from error
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _select_source(repository_root: Path, source: object) -> DocumentSet:
|
|
56
|
+
if isinstance(source, ExplicitFiles):
|
|
57
|
+
all_documents = tuple(
|
|
58
|
+
_worktree_document(repository_root, each_path) for each_path in source.paths
|
|
59
|
+
)
|
|
60
|
+
return DocumentSet(all_documents, SelectionKind.FILES, repository_root)
|
|
61
|
+
if isinstance(source, StagedChanges):
|
|
62
|
+
return _select_changes(repository_root, True, None)
|
|
63
|
+
if isinstance(source, BaseChanges):
|
|
64
|
+
merge_base = git_bytes_for(
|
|
65
|
+
repository_root,
|
|
66
|
+
(constants.GIT_MERGE_BASE_ARGUMENT, source.revision, constants.GIT_HEAD_REFERENCE),
|
|
67
|
+
).decode(constants.UTF8_ENCODING).strip()
|
|
68
|
+
return _select_changes(repository_root, False, merge_base)
|
|
69
|
+
if isinstance(source, RepositoryTree):
|
|
70
|
+
return _select_repository_tree(repository_root)
|
|
71
|
+
if isinstance(source, TextDocument):
|
|
72
|
+
normalized_path = _normalize_path(repository_root, source.path)
|
|
73
|
+
document = Document(normalized_path, source.text, None, None, ContentOrigin.EDITOR)
|
|
74
|
+
return DocumentSet((document,), SelectionKind.TEXT, repository_root)
|
|
75
|
+
if isinstance(source, DocumentSet):
|
|
76
|
+
return _normalize_document_set(repository_root, source)
|
|
77
|
+
raise SelectionRunFatal("Unknown source selection")
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _validated_repository_root(repository_root: Path) -> Path:
|
|
81
|
+
resolved_root = repository_root.expanduser().resolve()
|
|
82
|
+
if not resolved_root.is_dir():
|
|
83
|
+
raise SelectionRunFatal(f"Repository root is not a directory: {repository_root}")
|
|
84
|
+
git_root = git_bytes_for(resolved_root, constants.ALL_GIT_ROOT_ARGUMENTS).decode(
|
|
85
|
+
constants.UTF8_ENCODING
|
|
86
|
+
).strip()
|
|
87
|
+
if resolved_root != Path(git_root).resolve():
|
|
88
|
+
raise SelectionRunFatal(f"Repository root is not the Git root: {git_root}")
|
|
89
|
+
return resolved_root
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _select_repository_tree(repository_root: Path) -> DocumentSet:
|
|
93
|
+
all_documents = tuple(
|
|
94
|
+
_tracked_worktree_document(repository_root, each_path)
|
|
95
|
+
for each_path in _tracked_paths(repository_root)
|
|
96
|
+
if (repository_root / each_path).is_file()
|
|
97
|
+
)
|
|
98
|
+
return DocumentSet(all_documents, SelectionKind.REPOSITORY, repository_root)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _tracked_worktree_document(repository_root: Path, raw_path: str) -> Document:
|
|
102
|
+
relative_path = _normalize_tracked_path(repository_root, raw_path)
|
|
103
|
+
absolute_path = repository_root.joinpath(*Path(raw_path).parts)
|
|
104
|
+
return Document(
|
|
105
|
+
relative_path,
|
|
106
|
+
_read_worktree_text(absolute_path),
|
|
107
|
+
None,
|
|
108
|
+
None,
|
|
109
|
+
ContentOrigin.WORKTREE,
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _tracked_paths(repository_root: Path) -> tuple[str, ...]:
|
|
114
|
+
raw_paths = git_bytes_for(
|
|
115
|
+
repository_root,
|
|
116
|
+
(constants.GIT_FILES_ARGUMENT, constants.GIT_ZERO_TERMINATED_FLAG),
|
|
117
|
+
)
|
|
118
|
+
return split_nul_tokens(raw_paths)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _select_changes(
|
|
122
|
+
repository_root: Path,
|
|
123
|
+
is_staged: bool,
|
|
124
|
+
base_revision: str | None,
|
|
125
|
+
) -> DocumentSet:
|
|
126
|
+
all_arguments = [constants.GIT_DIFF_ARGUMENT]
|
|
127
|
+
if is_staged:
|
|
128
|
+
all_arguments.append(constants.GIT_CACHED_FLAG)
|
|
129
|
+
if base_revision is not None:
|
|
130
|
+
all_arguments.append(base_revision)
|
|
131
|
+
all_arguments.extend(
|
|
132
|
+
[constants.GIT_NAME_STATUS_FLAG, constants.GIT_FIND_RENAMES_FLAG,
|
|
133
|
+
constants.GIT_ZERO_TERMINATED_FLAG, constants.GIT_SEPARATOR]
|
|
134
|
+
)
|
|
135
|
+
all_records = _parse_change_records(git_bytes_for(repository_root, tuple(all_arguments)))
|
|
136
|
+
return _documents_from_changes(repository_root, all_records, is_staged, base_revision)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _parse_change_records(raw_bytes: bytes) -> tuple[tuple[str, tuple[str, ...]], ...]:
|
|
140
|
+
all_tokens = raw_bytes.split(constants.NUL_BYTE)
|
|
141
|
+
all_records: list[tuple[str, tuple[str, ...]]] = []
|
|
142
|
+
token_index = 0
|
|
143
|
+
while token_index < len(all_tokens) and all_tokens[token_index]:
|
|
144
|
+
status = all_tokens[token_index].decode(constants.UTF8_ENCODING)
|
|
145
|
+
token_index += 1
|
|
146
|
+
path_count = constants.RENAME_PATH_COUNT if status[:1] in {"R", "C"} else 1
|
|
147
|
+
if token_index + path_count > len(all_tokens):
|
|
148
|
+
raise SelectionRunFatal("Git returned an incomplete path record")
|
|
149
|
+
all_paths = tuple(
|
|
150
|
+
all_tokens[token_index + each_offset].decode(constants.UTF8_ENCODING)
|
|
151
|
+
for each_offset in range(path_count)
|
|
152
|
+
)
|
|
153
|
+
token_index += path_count
|
|
154
|
+
all_records.append((status[:1], all_paths))
|
|
155
|
+
return tuple(all_records)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _documents_from_changes(
|
|
159
|
+
repository_root: Path,
|
|
160
|
+
all_records: tuple[tuple[str, tuple[str, ...]], ...],
|
|
161
|
+
is_staged: bool,
|
|
162
|
+
base_revision: str | None,
|
|
163
|
+
) -> DocumentSet:
|
|
164
|
+
all_documents: list[Document] = []
|
|
165
|
+
all_deleted_paths: list[PurePosixPath] = []
|
|
166
|
+
all_renamed_paths: list[tuple[PurePosixPath, PurePosixPath]] = []
|
|
167
|
+
for each_status, each_paths in all_records:
|
|
168
|
+
_append_change(
|
|
169
|
+
repository_root, each_status, each_paths, is_staged, base_revision,
|
|
170
|
+
all_documents, all_deleted_paths, all_renamed_paths,
|
|
171
|
+
)
|
|
172
|
+
selection_kind = SelectionKind.STAGED if is_staged else SelectionKind.BASE
|
|
173
|
+
return DocumentSet(
|
|
174
|
+
tuple(all_documents), selection_kind, repository_root,
|
|
175
|
+
tuple(all_deleted_paths), tuple(all_renamed_paths),
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _append_change(
|
|
180
|
+
repository_root: Path,
|
|
181
|
+
status: str,
|
|
182
|
+
all_paths: tuple[str, ...],
|
|
183
|
+
is_staged: bool,
|
|
184
|
+
base_revision: str | None,
|
|
185
|
+
all_documents: list[Document],
|
|
186
|
+
all_deleted_paths: list[PurePosixPath],
|
|
187
|
+
all_renamed_paths: list[tuple[PurePosixPath, PurePosixPath]],
|
|
188
|
+
) -> None:
|
|
189
|
+
old_path, new_path = all_paths if status[:1] in {"R", "C"} else (all_paths[0], all_paths[0])
|
|
190
|
+
normalized_old_path = _normalize_path(repository_root, old_path)
|
|
191
|
+
normalized_new_path = _normalize_path(repository_root, new_path)
|
|
192
|
+
document = _change_document(
|
|
193
|
+
repository_root, old_path, new_path, normalized_old_path,
|
|
194
|
+
normalized_new_path, is_staged, base_revision, status,
|
|
195
|
+
)
|
|
196
|
+
if document is None:
|
|
197
|
+
all_deleted_paths.append(normalized_old_path)
|
|
198
|
+
return
|
|
199
|
+
all_documents.append(document)
|
|
200
|
+
if document.prior_path is not None:
|
|
201
|
+
all_renamed_paths.append((normalized_old_path, normalized_new_path))
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _change_document(
|
|
205
|
+
repository_root: Path,
|
|
206
|
+
old_path: str,
|
|
207
|
+
new_path: str,
|
|
208
|
+
normalized_old_path: PurePosixPath,
|
|
209
|
+
normalized_new_path: PurePosixPath,
|
|
210
|
+
is_staged: bool,
|
|
211
|
+
base_revision: str | None,
|
|
212
|
+
status: str,
|
|
213
|
+
) -> Document | None:
|
|
214
|
+
if status == "D":
|
|
215
|
+
return None
|
|
216
|
+
is_copy = status[:1] == "C"
|
|
217
|
+
is_rename = status[:1] == "R"
|
|
218
|
+
prior_text = _change_prior_text(
|
|
219
|
+
repository_root, old_path, is_staged, base_revision, is_copy
|
|
220
|
+
)
|
|
221
|
+
current_text = _change_current_text(repository_root, new_path, is_staged)
|
|
222
|
+
if current_text is None:
|
|
223
|
+
raise SelectionRunFatal(f"Git blob is unavailable: {new_path}")
|
|
224
|
+
prior_path = normalized_old_path if is_rename else None
|
|
225
|
+
return Document(
|
|
226
|
+
normalized_new_path, current_text, prior_text,
|
|
227
|
+
_changed_lines(prior_text, current_text),
|
|
228
|
+
ContentOrigin.INDEX if is_staged else ContentOrigin.REVISION_DIFF,
|
|
229
|
+
prior_path,
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _change_prior_text(
|
|
234
|
+
repository_root: Path,
|
|
235
|
+
old_path: str,
|
|
236
|
+
is_staged: bool,
|
|
237
|
+
base_revision: str | None,
|
|
238
|
+
is_copy: bool,
|
|
239
|
+
) -> str | None:
|
|
240
|
+
prior_revision = head_revision(repository_root) if is_staged else base_revision
|
|
241
|
+
if prior_revision is None or is_copy:
|
|
242
|
+
return None
|
|
243
|
+
return read_blob(repository_root, prior_revision, old_path)
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _change_current_text(
|
|
247
|
+
repository_root: Path, new_path: str, is_staged: bool
|
|
248
|
+
) -> str | None:
|
|
249
|
+
if is_staged:
|
|
250
|
+
return read_blob(repository_root, constants.GIT_INDEX_REFERENCE_PREFIX, new_path)
|
|
251
|
+
return _optional_worktree_text(repository_root, new_path)
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def _worktree_document(repository_root: Path, file_path: Path) -> Document:
|
|
255
|
+
relative_path = _normalize_path(repository_root, file_path)
|
|
256
|
+
absolute_path = repository_root.joinpath(*relative_path.parts)
|
|
257
|
+
if not absolute_path.is_file():
|
|
258
|
+
raise SelectionRunFatal(f"File does not exist: {file_path}")
|
|
259
|
+
return Document(
|
|
260
|
+
relative_path, _read_worktree_text(absolute_path), None, None,
|
|
261
|
+
ContentOrigin.WORKTREE,
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def _optional_worktree_text(repository_root: Path, relative_path: str) -> str | None:
|
|
266
|
+
normalized_path = _normalize_path(repository_root, relative_path)
|
|
267
|
+
absolute_path = repository_root.joinpath(*normalized_path.parts)
|
|
268
|
+
if not absolute_path.is_file():
|
|
269
|
+
return None
|
|
270
|
+
return _read_worktree_text(absolute_path)
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def _read_worktree_text(file_path: Path) -> str:
|
|
274
|
+
try:
|
|
275
|
+
return file_path.read_bytes().decode(constants.UTF8_ENCODING)
|
|
276
|
+
except UnicodeDecodeError as error:
|
|
277
|
+
raise SelectionRunFatal(f"File is not UTF-8: {file_path}") from error
|
|
278
|
+
except OSError as error:
|
|
279
|
+
raise SelectionRunFatal(f"File cannot be read: {file_path}") from error
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def _normalize_path(
|
|
283
|
+
repository_root: Path, file_path: Path | PurePosixPath | str
|
|
284
|
+
) -> PurePosixPath:
|
|
285
|
+
candidate_path = Path(file_path)
|
|
286
|
+
absolute_path = candidate_path if candidate_path.is_absolute() else repository_root / candidate_path
|
|
287
|
+
resolved_path = absolute_path.resolve(strict=False)
|
|
288
|
+
try:
|
|
289
|
+
relative_path = resolved_path.relative_to(repository_root)
|
|
290
|
+
except ValueError as error:
|
|
291
|
+
raise SelectionRunFatal(f"Path is outside the repository: {file_path}") from error
|
|
292
|
+
if relative_path == Path():
|
|
293
|
+
raise SelectionRunFatal(f"Path is the repository root: {file_path}")
|
|
294
|
+
return PurePosixPath(relative_path.as_posix().replace(os.sep, constants.PATH_SEPARATOR))
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def _normalize_tracked_path(repository_root: Path, raw_path: str) -> PurePosixPath:
|
|
298
|
+
candidate_path = Path(raw_path)
|
|
299
|
+
if candidate_path.is_absolute():
|
|
300
|
+
raise SelectionRunFatal(f"Tracked path is not relative: {raw_path}")
|
|
301
|
+
resolved_path = (repository_root / candidate_path).resolve(strict=False)
|
|
302
|
+
try:
|
|
303
|
+
resolved_path.relative_to(repository_root)
|
|
304
|
+
except ValueError as error:
|
|
305
|
+
raise SelectionRunFatal(f"Tracked path is outside the repository: {raw_path}") from error
|
|
306
|
+
if candidate_path == Path():
|
|
307
|
+
raise SelectionRunFatal(f"Tracked path is the repository root: {raw_path}")
|
|
308
|
+
return PurePosixPath(raw_path.replace("\\", constants.PATH_SEPARATOR))
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def _normalize_document(document: Document, repository_root: Path) -> Document:
|
|
312
|
+
prior_path = (
|
|
313
|
+
None
|
|
314
|
+
if document.prior_path is None
|
|
315
|
+
else _normalize_path(repository_root, document.prior_path)
|
|
316
|
+
)
|
|
317
|
+
return Document(
|
|
318
|
+
_normalize_path(repository_root, document.path),
|
|
319
|
+
document.text,
|
|
320
|
+
document.prior_text,
|
|
321
|
+
document.changed_lines,
|
|
322
|
+
document.origin,
|
|
323
|
+
prior_path,
|
|
324
|
+
)
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def _normalize_renamed_path(
|
|
328
|
+
repository_root: Path,
|
|
329
|
+
all_renamed_path: tuple[PurePosixPath, PurePosixPath],
|
|
330
|
+
) -> tuple[PurePosixPath, PurePosixPath]:
|
|
331
|
+
old_path, new_path = all_renamed_path
|
|
332
|
+
return (
|
|
333
|
+
_normalize_path(repository_root, old_path),
|
|
334
|
+
_normalize_path(repository_root, new_path),
|
|
335
|
+
)
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def _normalize_document_set(repository_root: Path, document_set: DocumentSet) -> DocumentSet:
|
|
339
|
+
all_documents = tuple(
|
|
340
|
+
_normalize_document(each_document, repository_root)
|
|
341
|
+
for each_document in document_set.documents
|
|
342
|
+
)
|
|
343
|
+
all_deleted_paths = tuple(
|
|
344
|
+
_normalize_path(repository_root, each_path)
|
|
345
|
+
for each_path in document_set.deleted_paths
|
|
346
|
+
)
|
|
347
|
+
all_renamed_paths = tuple(
|
|
348
|
+
_normalize_renamed_path(repository_root, each_path)
|
|
349
|
+
for each_path in document_set.renamed_paths
|
|
350
|
+
)
|
|
351
|
+
return DocumentSet(
|
|
352
|
+
all_documents,
|
|
353
|
+
document_set.selection,
|
|
354
|
+
repository_root,
|
|
355
|
+
all_deleted_paths,
|
|
356
|
+
all_renamed_paths,
|
|
357
|
+
)
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def _changed_lines(prior_text: str | None, current_text: str) -> frozenset[int] | None:
|
|
361
|
+
if prior_text is None:
|
|
362
|
+
return frozenset(
|
|
363
|
+
range(constants.NEW_DOCUMENT_ORIGIN_LINE, len(current_text.splitlines()) + 1)
|
|
364
|
+
)
|
|
365
|
+
matcher = difflib.SequenceMatcher(
|
|
366
|
+
a=prior_text.splitlines(), b=current_text.splitlines(), autojunk=False
|
|
367
|
+
)
|
|
368
|
+
all_changed_lines: set[int] = set()
|
|
369
|
+
for each_tag, _, _, each_start, each_end in matcher.get_opcodes():
|
|
370
|
+
if each_tag in constants.ALL_DIFF_CHANGED_TAGS:
|
|
371
|
+
all_changed_lines.update(range(each_start + 1, each_end + 1))
|
|
372
|
+
return frozenset(all_changed_lines)
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import subprocess
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from .config.constants import (
|
|
8
|
+
ALL_GIT_BLOB_ARGUMENTS,
|
|
9
|
+
ALL_GIT_HEAD_ARGUMENTS,
|
|
10
|
+
ALL_MISSING_BLOB_ERROR_FRAGMENTS,
|
|
11
|
+
ALL_UNBORN_HEAD_ERROR_FRAGMENTS,
|
|
12
|
+
GIT_ENVIRONMENT_VARIABLE_PREFIX,
|
|
13
|
+
GIT_EXECUTABLE,
|
|
14
|
+
NUL_BYTE,
|
|
15
|
+
UTF8_ENCODING,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class GitSelectionError(ValueError):
|
|
20
|
+
"""Raised when Git cannot provide the requested source bytes."""
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def git_bytes_for(repository_root: Path, all_arguments: tuple[str, ...]) -> bytes:
|
|
24
|
+
"""Run a fixed Git argument vector and return raw stdout bytes.
|
|
25
|
+
|
|
26
|
+
Args:
|
|
27
|
+
repository_root: Repository root used as the process directory.
|
|
28
|
+
all_arguments: Git arguments without the executable name.
|
|
29
|
+
|
|
30
|
+
Returns:
|
|
31
|
+
Raw Git stdout bytes.
|
|
32
|
+
|
|
33
|
+
Raises:
|
|
34
|
+
GitSelectionError: If Git returns a failure status.
|
|
35
|
+
"""
|
|
36
|
+
completed = subprocess.run(
|
|
37
|
+
[GIT_EXECUTABLE, *all_arguments],
|
|
38
|
+
cwd=repository_root,
|
|
39
|
+
capture_output=True,
|
|
40
|
+
check=False,
|
|
41
|
+
env=_git_subprocess_environment(),
|
|
42
|
+
)
|
|
43
|
+
if completed.returncode != 0:
|
|
44
|
+
error_text = completed.stderr.decode(UTF8_ENCODING, errors="replace").strip()
|
|
45
|
+
raise GitSelectionError(error_text or "Git command failed")
|
|
46
|
+
return completed.stdout
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def head_revision(repository_root: Path) -> str | None:
|
|
50
|
+
"""Return the current commit or None for an unborn repository.
|
|
51
|
+
|
|
52
|
+
Args:
|
|
53
|
+
repository_root: Repository root used as the process directory.
|
|
54
|
+
|
|
55
|
+
Returns:
|
|
56
|
+
The current commit name, or None when HEAD does not exist.
|
|
57
|
+
|
|
58
|
+
Raises:
|
|
59
|
+
GitSelectionError: If Git reports an unexpected HEAD failure.
|
|
60
|
+
"""
|
|
61
|
+
completed = subprocess.run(
|
|
62
|
+
[GIT_EXECUTABLE, *ALL_GIT_HEAD_ARGUMENTS],
|
|
63
|
+
cwd=repository_root,
|
|
64
|
+
capture_output=True,
|
|
65
|
+
check=False,
|
|
66
|
+
env=_git_subprocess_environment(),
|
|
67
|
+
)
|
|
68
|
+
if completed.returncode != 0:
|
|
69
|
+
error_text = completed.stderr.decode(UTF8_ENCODING, errors="replace").strip()
|
|
70
|
+
if any(
|
|
71
|
+
each_fragment in error_text.casefold()
|
|
72
|
+
for each_fragment in ALL_UNBORN_HEAD_ERROR_FRAGMENTS
|
|
73
|
+
):
|
|
74
|
+
return None
|
|
75
|
+
raise GitSelectionError(error_text or "Git HEAD lookup failed")
|
|
76
|
+
return completed.stdout.decode(UTF8_ENCODING).strip()
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def read_blob(repository_root: Path, revision: str, relative_path: str) -> str | None:
|
|
80
|
+
"""Read one Git blob as UTF-8 text.
|
|
81
|
+
|
|
82
|
+
Args:
|
|
83
|
+
repository_root: Repository root used as the process directory.
|
|
84
|
+
revision: Commit, tree, or index revision prefix.
|
|
85
|
+
relative_path: Repository-relative path.
|
|
86
|
+
|
|
87
|
+
Returns:
|
|
88
|
+
Decoded blob text, or None when the blob is absent.
|
|
89
|
+
|
|
90
|
+
Raises:
|
|
91
|
+
GitSelectionError: If the blob is not UTF-8.
|
|
92
|
+
"""
|
|
93
|
+
blob_bytes = _read_blob_bytes(repository_root, revision, relative_path)
|
|
94
|
+
if blob_bytes is None:
|
|
95
|
+
return None
|
|
96
|
+
try:
|
|
97
|
+
return blob_bytes.decode(UTF8_ENCODING)
|
|
98
|
+
except UnicodeDecodeError as error:
|
|
99
|
+
raise GitSelectionError(f"Git blob is not UTF-8: {relative_path}") from error
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _git_subprocess_environment() -> dict[str, str]:
|
|
103
|
+
git_prefix = GIT_ENVIRONMENT_VARIABLE_PREFIX
|
|
104
|
+
return {
|
|
105
|
+
each_name: each_environment_text
|
|
106
|
+
for each_name, each_environment_text in os.environ.items()
|
|
107
|
+
if not each_name.upper().startswith(git_prefix)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _read_blob_bytes(repository_root: Path, revision: str, relative_path: str) -> bytes | None:
|
|
112
|
+
completed = subprocess.run(
|
|
113
|
+
[
|
|
114
|
+
GIT_EXECUTABLE,
|
|
115
|
+
*ALL_GIT_BLOB_ARGUMENTS,
|
|
116
|
+
f"{revision}:{relative_path}",
|
|
117
|
+
],
|
|
118
|
+
cwd=repository_root,
|
|
119
|
+
capture_output=True,
|
|
120
|
+
check=False,
|
|
121
|
+
env=_git_subprocess_environment(),
|
|
122
|
+
)
|
|
123
|
+
if completed.returncode == 0:
|
|
124
|
+
return completed.stdout
|
|
125
|
+
error_text = completed.stderr.decode(UTF8_ENCODING, errors="replace").strip()
|
|
126
|
+
if any(
|
|
127
|
+
each_fragment in error_text.casefold()
|
|
128
|
+
for each_fragment in ALL_MISSING_BLOB_ERROR_FRAGMENTS
|
|
129
|
+
):
|
|
130
|
+
return None
|
|
131
|
+
raise GitSelectionError(error_text or "Git blob read failed")
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def split_nul_tokens(raw_bytes: bytes) -> tuple[str, ...]:
|
|
135
|
+
"""Decode a NUL-separated Git path response.
|
|
136
|
+
|
|
137
|
+
Args:
|
|
138
|
+
raw_bytes: NUL-separated Git response bytes.
|
|
139
|
+
|
|
140
|
+
Returns:
|
|
141
|
+
Non-empty UTF-8 path tokens.
|
|
142
|
+
"""
|
|
143
|
+
return tuple(
|
|
144
|
+
each_token.decode(UTF8_ENCODING)
|
|
145
|
+
for each_token in raw_bytes.split(NUL_BYTE)
|
|
146
|
+
if each_token
|
|
147
|
+
)
|