indent 0.0.8__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.
Potentially problematic release.
This version of indent might be problematic. Click here for more details.
- exponent/__init__.py +1 -0
- exponent/cli.py +112 -0
- exponent/commands/cloud_commands.py +85 -0
- exponent/commands/common.py +434 -0
- exponent/commands/config_commands.py +581 -0
- exponent/commands/github_app_commands.py +211 -0
- exponent/commands/listen_commands.py +96 -0
- exponent/commands/run_commands.py +208 -0
- exponent/commands/settings.py +56 -0
- exponent/commands/shell_commands.py +2840 -0
- exponent/commands/theme.py +246 -0
- exponent/commands/types.py +111 -0
- exponent/commands/upgrade.py +29 -0
- exponent/commands/utils.py +236 -0
- exponent/core/config.py +180 -0
- exponent/core/graphql/__init__.py +0 -0
- exponent/core/graphql/client.py +59 -0
- exponent/core/graphql/cloud_config_queries.py +77 -0
- exponent/core/graphql/get_chats_query.py +47 -0
- exponent/core/graphql/github_config_queries.py +56 -0
- exponent/core/graphql/mutations.py +75 -0
- exponent/core/graphql/queries.py +110 -0
- exponent/core/graphql/subscriptions.py +452 -0
- exponent/core/remote_execution/checkpoints.py +212 -0
- exponent/core/remote_execution/cli_rpc_types.py +214 -0
- exponent/core/remote_execution/client.py +545 -0
- exponent/core/remote_execution/code_execution.py +58 -0
- exponent/core/remote_execution/command_execution.py +105 -0
- exponent/core/remote_execution/error_info.py +45 -0
- exponent/core/remote_execution/exceptions.py +10 -0
- exponent/core/remote_execution/file_write.py +410 -0
- exponent/core/remote_execution/files.py +415 -0
- exponent/core/remote_execution/git.py +268 -0
- exponent/core/remote_execution/languages/python_execution.py +239 -0
- exponent/core/remote_execution/languages/shell_streaming.py +221 -0
- exponent/core/remote_execution/languages/types.py +20 -0
- exponent/core/remote_execution/session.py +128 -0
- exponent/core/remote_execution/system_context.py +54 -0
- exponent/core/remote_execution/tool_execution.py +289 -0
- exponent/core/remote_execution/truncation.py +284 -0
- exponent/core/remote_execution/types.py +670 -0
- exponent/core/remote_execution/utils.py +600 -0
- exponent/core/types/__init__.py +0 -0
- exponent/core/types/command_data.py +206 -0
- exponent/core/types/event_types.py +89 -0
- exponent/core/types/generated/__init__.py +0 -0
- exponent/core/types/generated/strategy_info.py +225 -0
- exponent/migration-docs/login.md +112 -0
- exponent/py.typed +4 -0
- exponent/utils/__init__.py +0 -0
- exponent/utils/colors.py +92 -0
- exponent/utils/version.py +289 -0
- indent-0.0.8.dist-info/METADATA +36 -0
- indent-0.0.8.dist-info/RECORD +56 -0
- indent-0.0.8.dist-info/WHEEL +4 -0
- indent-0.0.8.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,415 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from asyncio import gather, to_thread
|
|
3
|
+
from typing import Final, cast
|
|
4
|
+
|
|
5
|
+
from anyio import Path as AsyncPath
|
|
6
|
+
from python_ripgrep import PySortMode, PySortModeKind, files, search
|
|
7
|
+
from rapidfuzz import process
|
|
8
|
+
|
|
9
|
+
from exponent.core.remote_execution.types import (
|
|
10
|
+
FileAttachment,
|
|
11
|
+
FilePath,
|
|
12
|
+
GetAllTrackedFilesRequest,
|
|
13
|
+
GetAllTrackedFilesResponse,
|
|
14
|
+
GetFileAttachmentRequest,
|
|
15
|
+
GetFileAttachmentResponse,
|
|
16
|
+
GetFileAttachmentsRequest,
|
|
17
|
+
GetFileAttachmentsResponse,
|
|
18
|
+
GetMatchingFilesRequest,
|
|
19
|
+
GetMatchingFilesResponse,
|
|
20
|
+
ListFilesRequest,
|
|
21
|
+
ListFilesResponse,
|
|
22
|
+
RemoteFile,
|
|
23
|
+
)
|
|
24
|
+
from exponent.core.remote_execution.utils import safe_read_file
|
|
25
|
+
|
|
26
|
+
MAX_MATCHING_FILES: Final[int] = 10
|
|
27
|
+
FILE_NOT_FOUND: Final[str] = "File {} does not exist"
|
|
28
|
+
MAX_FILES_TO_WALK: Final[int] = 10_000
|
|
29
|
+
|
|
30
|
+
GLOB_MAX_COUNT: Final[int] = 1000
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class FileCache:
|
|
34
|
+
"""A cache of the files in a working directory.
|
|
35
|
+
|
|
36
|
+
Args:
|
|
37
|
+
working_directory: The working directory to cache the files from.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
def __init__(self, working_directory: str) -> None:
|
|
41
|
+
self.working_directory = working_directory
|
|
42
|
+
self._cache: list[str] | None = None
|
|
43
|
+
|
|
44
|
+
async def get_files(self) -> list[str]:
|
|
45
|
+
"""Get the files in the working directory.
|
|
46
|
+
|
|
47
|
+
Returns:
|
|
48
|
+
A list of file paths in the working directory.
|
|
49
|
+
"""
|
|
50
|
+
if self._cache is None:
|
|
51
|
+
self._cache = await file_walk(self.working_directory)
|
|
52
|
+
|
|
53
|
+
return self._cache
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
async def list_files(list_files_request: ListFilesRequest) -> ListFilesResponse:
|
|
57
|
+
"""Get a list of files in the specified directory.
|
|
58
|
+
|
|
59
|
+
Args:
|
|
60
|
+
list_files_request: An object containing the directory to list files from.
|
|
61
|
+
|
|
62
|
+
Returns:
|
|
63
|
+
A list of RemoteFile objects representing the files in the directory.
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
filenames = [
|
|
67
|
+
entry.name async for entry in AsyncPath(list_files_request.directory).iterdir()
|
|
68
|
+
]
|
|
69
|
+
|
|
70
|
+
return ListFilesResponse(
|
|
71
|
+
files=[
|
|
72
|
+
RemoteFile(
|
|
73
|
+
file_path=filename,
|
|
74
|
+
working_directory=list_files_request.directory,
|
|
75
|
+
)
|
|
76
|
+
for filename in filenames
|
|
77
|
+
],
|
|
78
|
+
correlation_id=list_files_request.correlation_id,
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
async def get_file_content(
|
|
83
|
+
absolute_path: FilePath, offset: int | None = None, limit: int | None = None
|
|
84
|
+
) -> tuple[str, bool]:
|
|
85
|
+
"""Get the content of the file at the specified path.
|
|
86
|
+
|
|
87
|
+
Args:
|
|
88
|
+
absolute_path: The absolute path to the file.
|
|
89
|
+
|
|
90
|
+
Returns:
|
|
91
|
+
A tuple containing the content of the file and a boolean indicating if the file exists.
|
|
92
|
+
"""
|
|
93
|
+
file = AsyncPath(absolute_path)
|
|
94
|
+
exists = await file.exists()
|
|
95
|
+
|
|
96
|
+
if not exists:
|
|
97
|
+
return FILE_NOT_FOUND.format(absolute_path), False
|
|
98
|
+
|
|
99
|
+
if await file.is_dir():
|
|
100
|
+
return "File is a directory", True
|
|
101
|
+
|
|
102
|
+
content = await safe_read_file(file)
|
|
103
|
+
|
|
104
|
+
if offset or limit:
|
|
105
|
+
offset = offset or 0
|
|
106
|
+
limit = limit or -1
|
|
107
|
+
|
|
108
|
+
content_lines = content.splitlines()
|
|
109
|
+
content_lines = content_lines[offset:]
|
|
110
|
+
content_lines = content_lines[:limit]
|
|
111
|
+
|
|
112
|
+
content = "\n".join(content_lines)
|
|
113
|
+
|
|
114
|
+
return content, exists
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
async def get_file_attachments(
|
|
118
|
+
get_file_attachments_request: GetFileAttachmentsRequest,
|
|
119
|
+
client_working_directory: str,
|
|
120
|
+
) -> GetFileAttachmentsResponse:
|
|
121
|
+
"""Get the content of the files at the specified paths.
|
|
122
|
+
|
|
123
|
+
Args:
|
|
124
|
+
get_file_attachments_request: An object containing the file paths.
|
|
125
|
+
client_working_directory: The working directory of the client.
|
|
126
|
+
|
|
127
|
+
Returns:
|
|
128
|
+
A list of FileAttachment objects containing the content of the files.
|
|
129
|
+
"""
|
|
130
|
+
remote_files = get_file_attachments_request.files
|
|
131
|
+
attachments = await gather(
|
|
132
|
+
*[
|
|
133
|
+
get_file_content(
|
|
134
|
+
AsyncPath(client_working_directory) / remote_file.file_path
|
|
135
|
+
)
|
|
136
|
+
for remote_file in remote_files
|
|
137
|
+
]
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
files = [
|
|
141
|
+
FileAttachment(attachment_type="file", file=remote_file, content=content)
|
|
142
|
+
for remote_file, (content, _) in zip(remote_files, attachments)
|
|
143
|
+
]
|
|
144
|
+
|
|
145
|
+
return GetFileAttachmentsResponse(
|
|
146
|
+
correlation_id=get_file_attachments_request.correlation_id,
|
|
147
|
+
file_attachments=files,
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
async def get_file_attachment(
|
|
152
|
+
get_file_attachment_request: GetFileAttachmentRequest, client_working_directory: str
|
|
153
|
+
) -> GetFileAttachmentResponse:
|
|
154
|
+
"""Get the content of the file at the specified path.
|
|
155
|
+
|
|
156
|
+
Args:
|
|
157
|
+
get_file_attachment_request: An object containing the file path.
|
|
158
|
+
client_working_directory: The working directory of the client.
|
|
159
|
+
|
|
160
|
+
Returns:
|
|
161
|
+
A FileAttachment object containing the content of the file.
|
|
162
|
+
"""
|
|
163
|
+
file = get_file_attachment_request.file
|
|
164
|
+
absolute_path = await file.resolve(client_working_directory)
|
|
165
|
+
|
|
166
|
+
content, exists = await get_file_content(absolute_path)
|
|
167
|
+
|
|
168
|
+
return GetFileAttachmentResponse(
|
|
169
|
+
content=content,
|
|
170
|
+
exists=exists,
|
|
171
|
+
file=file,
|
|
172
|
+
correlation_id=get_file_attachment_request.correlation_id,
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
async def get_matching_files(
|
|
177
|
+
search_term: GetMatchingFilesRequest,
|
|
178
|
+
file_cache: FileCache,
|
|
179
|
+
) -> GetMatchingFilesResponse:
|
|
180
|
+
"""Get the files that match the search term.
|
|
181
|
+
|
|
182
|
+
Args:
|
|
183
|
+
search_term: The search term to match against the files.
|
|
184
|
+
file_cache: A cache of the files in the working directory.
|
|
185
|
+
|
|
186
|
+
Returns:
|
|
187
|
+
A list of RemoteFile objects that match the search term.
|
|
188
|
+
"""
|
|
189
|
+
# Use rapidfuzz to find the best matching files
|
|
190
|
+
matching_files = await to_thread(
|
|
191
|
+
process.extract,
|
|
192
|
+
search_term.search_term,
|
|
193
|
+
await file_cache.get_files(),
|
|
194
|
+
limit=MAX_MATCHING_FILES,
|
|
195
|
+
score_cutoff=0,
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
directory = file_cache.working_directory
|
|
199
|
+
files: list[RemoteFile] = [
|
|
200
|
+
RemoteFile(file_path=file, working_directory=directory)
|
|
201
|
+
for file, _, _ in matching_files
|
|
202
|
+
]
|
|
203
|
+
|
|
204
|
+
return GetMatchingFilesResponse(
|
|
205
|
+
files=files,
|
|
206
|
+
correlation_id=search_term.correlation_id,
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
async def get_all_tracked_files(
|
|
211
|
+
request: GetAllTrackedFilesRequest,
|
|
212
|
+
working_directory: str,
|
|
213
|
+
) -> GetAllTrackedFilesResponse:
|
|
214
|
+
return GetAllTrackedFilesResponse(
|
|
215
|
+
correlation_id=request.correlation_id,
|
|
216
|
+
files=await get_all_non_ignored_files(working_directory),
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
async def search_files(
|
|
221
|
+
path_str: str,
|
|
222
|
+
file_pattern: str | None,
|
|
223
|
+
regex: str,
|
|
224
|
+
working_directory: str,
|
|
225
|
+
) -> list[str]:
|
|
226
|
+
path = AsyncPath(working_directory) / path_str
|
|
227
|
+
path_resolved = await path.resolve()
|
|
228
|
+
globs = [file_pattern] if file_pattern else None
|
|
229
|
+
|
|
230
|
+
return await to_thread(
|
|
231
|
+
search,
|
|
232
|
+
patterns=[regex],
|
|
233
|
+
paths=[str(path_resolved)],
|
|
234
|
+
globs=globs,
|
|
235
|
+
after_context=3,
|
|
236
|
+
before_context=5,
|
|
237
|
+
heading=True,
|
|
238
|
+
separator_field_context="|",
|
|
239
|
+
separator_field_match="|",
|
|
240
|
+
separator_context="\n...\n",
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
async def get_all_file_contents(
|
|
245
|
+
working_directory: str,
|
|
246
|
+
) -> list[list[str]]:
|
|
247
|
+
path_resolved = await AsyncPath(working_directory).resolve()
|
|
248
|
+
|
|
249
|
+
results = await to_thread(
|
|
250
|
+
search,
|
|
251
|
+
patterns=[".*"],
|
|
252
|
+
paths=[str(path_resolved)],
|
|
253
|
+
globs=["!**/poetry.lock", "!**/pnpm-lock.yaml"],
|
|
254
|
+
heading=True,
|
|
255
|
+
line_number=False,
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
result_sizes = [len(result) for result in results]
|
|
259
|
+
total_size = sum(result_sizes)
|
|
260
|
+
batch_size = total_size // 10
|
|
261
|
+
|
|
262
|
+
batches = []
|
|
263
|
+
current_batch: list[str] = []
|
|
264
|
+
current_size = 0
|
|
265
|
+
|
|
266
|
+
for i, result in enumerate(results):
|
|
267
|
+
if current_size + result_sizes[i] > batch_size:
|
|
268
|
+
batches.append(current_batch)
|
|
269
|
+
current_batch = []
|
|
270
|
+
current_size = 0
|
|
271
|
+
|
|
272
|
+
current_batch.append(result)
|
|
273
|
+
current_size += result_sizes[i]
|
|
274
|
+
|
|
275
|
+
batches.append(current_batch)
|
|
276
|
+
|
|
277
|
+
return batches
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
async def normalize_files(
|
|
281
|
+
working_directory: str, file_paths: list[FilePath]
|
|
282
|
+
) -> list[RemoteFile]:
|
|
283
|
+
"""Normalize file paths to be relative to the working directory.
|
|
284
|
+
|
|
285
|
+
Args:
|
|
286
|
+
working_directory: The working directory to normalize the file paths against.
|
|
287
|
+
file_paths: A list of file paths to normalize.
|
|
288
|
+
|
|
289
|
+
Returns:
|
|
290
|
+
A list of RemoteFile objects with normalized file paths.
|
|
291
|
+
"""
|
|
292
|
+
working_path = await AsyncPath(working_directory).resolve()
|
|
293
|
+
normalized_files = []
|
|
294
|
+
|
|
295
|
+
for file_path in file_paths:
|
|
296
|
+
path = AsyncPath(file_path)
|
|
297
|
+
|
|
298
|
+
if path.is_absolute():
|
|
299
|
+
path = path.relative_to(working_path)
|
|
300
|
+
|
|
301
|
+
normalized_files.append(
|
|
302
|
+
RemoteFile(
|
|
303
|
+
file_path=str(path),
|
|
304
|
+
working_directory=working_directory,
|
|
305
|
+
)
|
|
306
|
+
)
|
|
307
|
+
|
|
308
|
+
return sorted(normalized_files)
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def _format_ignore_globs(ignore_extra: list[str] | None) -> list[str]:
|
|
312
|
+
if ignore_extra is None:
|
|
313
|
+
return []
|
|
314
|
+
|
|
315
|
+
return [f"!**/{ignore}" for ignore in ignore_extra]
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
async def file_walk(
|
|
319
|
+
directory: str,
|
|
320
|
+
ignore_extra: list[str] | None = None,
|
|
321
|
+
max_files: int = MAX_FILES_TO_WALK,
|
|
322
|
+
) -> list[str]:
|
|
323
|
+
"""
|
|
324
|
+
Walk through a directory and return all file paths, respecting .gitignore and additional ignore patterns.
|
|
325
|
+
|
|
326
|
+
Args:
|
|
327
|
+
directory: The directory to walk through
|
|
328
|
+
ignore_extra: Additional directory paths to ignore, follows the gitignore format.
|
|
329
|
+
max_files: The maximal number of files to return
|
|
330
|
+
|
|
331
|
+
Returns:
|
|
332
|
+
A list of file paths in the directory.
|
|
333
|
+
"""
|
|
334
|
+
working_path = str(await AsyncPath(directory).resolve())
|
|
335
|
+
|
|
336
|
+
results: list[str] = await to_thread(
|
|
337
|
+
files,
|
|
338
|
+
patterns=[""],
|
|
339
|
+
paths=[working_path],
|
|
340
|
+
globs=_format_ignore_globs(ignore_extra),
|
|
341
|
+
sort=PySortMode(kind=PySortModeKind.Path),
|
|
342
|
+
max_count=max_files,
|
|
343
|
+
)
|
|
344
|
+
|
|
345
|
+
# Create relative paths using os.path functions which handle platform differences
|
|
346
|
+
relative_results = []
|
|
347
|
+
for result in results:
|
|
348
|
+
# Check if the path is inside the working directory
|
|
349
|
+
if os.path.commonpath([working_path, result]) == working_path:
|
|
350
|
+
# Create relative path
|
|
351
|
+
rel_path = os.path.relpath(result, working_path)
|
|
352
|
+
relative_results.append(rel_path)
|
|
353
|
+
else:
|
|
354
|
+
# Fallback to just using the filename
|
|
355
|
+
relative_results.append(os.path.basename(result))
|
|
356
|
+
|
|
357
|
+
return relative_results
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
async def get_all_non_ignored_files(working_directory: str) -> list[RemoteFile]:
|
|
361
|
+
file_paths = await file_walk(working_directory, ignore_extra=DEFAULT_IGNORES)
|
|
362
|
+
|
|
363
|
+
return await normalize_files(working_directory, cast(list[FilePath], file_paths))
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
async def glob(
|
|
367
|
+
path: str,
|
|
368
|
+
glob_pattern: str,
|
|
369
|
+
) -> list[str]:
|
|
370
|
+
return await to_thread(
|
|
371
|
+
files,
|
|
372
|
+
patterns=[],
|
|
373
|
+
paths=[path],
|
|
374
|
+
globs=[glob_pattern],
|
|
375
|
+
sort=PySortMode(kind=PySortModeKind.Path),
|
|
376
|
+
max_count=GLOB_MAX_COUNT,
|
|
377
|
+
)
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
DEFAULT_IGNORES = [
|
|
381
|
+
"**/.git/",
|
|
382
|
+
".venv/",
|
|
383
|
+
".mypy_cache",
|
|
384
|
+
".pytest_cache",
|
|
385
|
+
"node_modules/",
|
|
386
|
+
"venv/",
|
|
387
|
+
".pyenv",
|
|
388
|
+
"__pycache__",
|
|
389
|
+
".ipynb_checkpoints",
|
|
390
|
+
".vercel",
|
|
391
|
+
"__pycache__/",
|
|
392
|
+
"*.py[cod]",
|
|
393
|
+
"*$py.class",
|
|
394
|
+
".env",
|
|
395
|
+
"*.so",
|
|
396
|
+
".Python",
|
|
397
|
+
"build/",
|
|
398
|
+
"develop-eggs/",
|
|
399
|
+
"dist/",
|
|
400
|
+
"downloads/",
|
|
401
|
+
"eggs/",
|
|
402
|
+
".eggs/",
|
|
403
|
+
"lib/",
|
|
404
|
+
"lib64/",
|
|
405
|
+
"parts/",
|
|
406
|
+
"sdist/",
|
|
407
|
+
"var/",
|
|
408
|
+
"wheels/",
|
|
409
|
+
"pip-wheel-metadata/",
|
|
410
|
+
"share/python-wheels/",
|
|
411
|
+
"*.egg-info/",
|
|
412
|
+
".installed.cfg",
|
|
413
|
+
"*.egg",
|
|
414
|
+
"MANIFEST",
|
|
415
|
+
]
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from collections.abc import Callable
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import cast
|
|
5
|
+
|
|
6
|
+
import pygit2
|
|
7
|
+
from anyio import Path as AsyncPath
|
|
8
|
+
from gitignore_parser import (
|
|
9
|
+
IgnoreRule,
|
|
10
|
+
handle_negation,
|
|
11
|
+
parse_gitignore,
|
|
12
|
+
rule_from_pattern,
|
|
13
|
+
)
|
|
14
|
+
from pygit2 import Tree
|
|
15
|
+
from pygit2.enums import DiffOption
|
|
16
|
+
from pygit2.repository import Repository
|
|
17
|
+
|
|
18
|
+
from exponent.core.remote_execution.types import (
|
|
19
|
+
GitInfo,
|
|
20
|
+
)
|
|
21
|
+
from exponent.core.remote_execution.utils import safe_read_file
|
|
22
|
+
|
|
23
|
+
GIT_OBJ_COMMIT = 1
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
async def git_file_walk(
|
|
27
|
+
repo: Repository,
|
|
28
|
+
directory: str,
|
|
29
|
+
) -> list[str]:
|
|
30
|
+
"""
|
|
31
|
+
Walk through a directory and return all file paths, respecting .gitignore and additional ignore patterns.
|
|
32
|
+
"""
|
|
33
|
+
tree = get_git_subtree_for_dir(repo, directory)
|
|
34
|
+
|
|
35
|
+
if not tree:
|
|
36
|
+
return []
|
|
37
|
+
|
|
38
|
+
# diff to the empty tree to see all files
|
|
39
|
+
tracked_diff = tree.diff_to_tree()
|
|
40
|
+
|
|
41
|
+
tracked_files = [delta.new_file.path for delta in tracked_diff.deltas]
|
|
42
|
+
|
|
43
|
+
# Find untracked files relative to the root
|
|
44
|
+
untracked_diff = repo.diff(flags=DiffOption.INCLUDE_UNTRACKED)
|
|
45
|
+
untracked_files_from_root = [
|
|
46
|
+
AsyncPath(delta.new_file.path) for delta in untracked_diff.deltas
|
|
47
|
+
]
|
|
48
|
+
|
|
49
|
+
# Current working directory relative to the repo root
|
|
50
|
+
dir_path = await AsyncPath(directory).resolve()
|
|
51
|
+
repo_path = await AsyncPath(repo.workdir).resolve()
|
|
52
|
+
|
|
53
|
+
if repo_path == dir_path:
|
|
54
|
+
relative_directory = str(repo_path)
|
|
55
|
+
else:
|
|
56
|
+
relative_directory = str(dir_path.relative_to(repo_path))
|
|
57
|
+
|
|
58
|
+
# Resolve all untracked files that are within the current working directory
|
|
59
|
+
untracked_files = []
|
|
60
|
+
for untracked_file in untracked_files_from_root:
|
|
61
|
+
if not untracked_file.is_relative_to(relative_directory):
|
|
62
|
+
continue
|
|
63
|
+
|
|
64
|
+
untracked_files.append(str(untracked_file.relative_to(relative_directory)))
|
|
65
|
+
|
|
66
|
+
# Combine both as sets to remove duplicates
|
|
67
|
+
return list(set(tracked_files) | set(untracked_files))
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def get_repo(working_directory: str) -> Repository | None:
|
|
71
|
+
try:
|
|
72
|
+
return Repository(working_directory)
|
|
73
|
+
except pygit2.GitError:
|
|
74
|
+
return None
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
async def get_git_info(working_directory: str) -> GitInfo | None:
|
|
78
|
+
try:
|
|
79
|
+
repo = Repository(working_directory)
|
|
80
|
+
except pygit2.GitError:
|
|
81
|
+
return None
|
|
82
|
+
|
|
83
|
+
return GitInfo(
|
|
84
|
+
branch=(await _get_git_branch(repo)) or "<unknown branch>",
|
|
85
|
+
remote=_get_git_remote(repo),
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def get_tracked_files_in_dir(
|
|
90
|
+
repo: Repository,
|
|
91
|
+
dir: str | Path,
|
|
92
|
+
filter_func: Callable[[str], bool] | None = None,
|
|
93
|
+
) -> list[str]:
|
|
94
|
+
rel_path = get_path_relative_to_repo_root(repo, dir)
|
|
95
|
+
dir_tree = get_git_subtree_for_dir(repo, dir)
|
|
96
|
+
entries: list[str] = []
|
|
97
|
+
if not dir_tree:
|
|
98
|
+
return entries
|
|
99
|
+
for entry in dir_tree:
|
|
100
|
+
if not entry.name:
|
|
101
|
+
continue
|
|
102
|
+
entry_path = str(Path(f"{repo.workdir}/{rel_path}/{entry.name}"))
|
|
103
|
+
if entry.type_str == "tree":
|
|
104
|
+
entries.extend(get_tracked_files_in_dir(repo, entry_path, filter_func))
|
|
105
|
+
elif entry.type_str == "blob":
|
|
106
|
+
if not filter_func or filter_func(entry.name):
|
|
107
|
+
entries.append(entry_path)
|
|
108
|
+
return entries
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def get_git_subtree_for_dir(repo: Repository, dir: str | Path) -> Tree | None:
|
|
112
|
+
rel_path = get_path_relative_to_repo_root(repo, dir)
|
|
113
|
+
|
|
114
|
+
try:
|
|
115
|
+
head_commit = repo.head.peel(GIT_OBJ_COMMIT)
|
|
116
|
+
except pygit2.GitError:
|
|
117
|
+
# If the repo is empty, then the head commit will not exist
|
|
118
|
+
return None
|
|
119
|
+
head_tree: Tree = head_commit.tree
|
|
120
|
+
|
|
121
|
+
if rel_path == Path("."):
|
|
122
|
+
# If the relative path is the root of the repo, then
|
|
123
|
+
# the head_tree is what we want. Note we do this because
|
|
124
|
+
# Passing "." or "" as the path into the tree will raise.
|
|
125
|
+
return head_tree
|
|
126
|
+
return cast(Tree, head_tree[str(rel_path)])
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def get_path_relative_to_repo_root(repo: Repository, path: str | Path) -> Path:
|
|
130
|
+
path = Path(path).resolve()
|
|
131
|
+
return path.relative_to(Path(repo.workdir).resolve())
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def get_local_commit_hash() -> str:
|
|
135
|
+
try:
|
|
136
|
+
# Open the repository (assumes the current working directory is within the git repo)
|
|
137
|
+
repo = Repository(os.getcwd())
|
|
138
|
+
|
|
139
|
+
# Get the current HEAD commit
|
|
140
|
+
head = repo.head
|
|
141
|
+
|
|
142
|
+
# Get the commit object and return its hash as a string
|
|
143
|
+
return str(repo[head.target].id)
|
|
144
|
+
except pygit2.GitError:
|
|
145
|
+
return "unknown-local-commit"
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _get_git_remote(repo: Repository) -> str | None:
|
|
149
|
+
if repo.remotes:
|
|
150
|
+
return str(repo.remotes[0].url)
|
|
151
|
+
return None
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
async def _get_git_branch(repo: Repository) -> str | None:
|
|
155
|
+
try:
|
|
156
|
+
# Look for HEAD file in the .git directory
|
|
157
|
+
head_path = AsyncPath(os.path.join(repo.path, "HEAD"))
|
|
158
|
+
|
|
159
|
+
if not await head_path.exists():
|
|
160
|
+
return None
|
|
161
|
+
|
|
162
|
+
head_content_raw = await safe_read_file(head_path)
|
|
163
|
+
head_content = head_content_raw.strip()
|
|
164
|
+
|
|
165
|
+
if head_content.startswith("ref:"):
|
|
166
|
+
return head_content.split("refs/heads/")[-1]
|
|
167
|
+
else:
|
|
168
|
+
return None
|
|
169
|
+
|
|
170
|
+
except Exception: # noqa: BLE001
|
|
171
|
+
return None
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
class GitIgnoreHandler:
|
|
175
|
+
def __init__(
|
|
176
|
+
self, working_directory: str, default_ignores: list[str] | None = None
|
|
177
|
+
):
|
|
178
|
+
self.checkers = {}
|
|
179
|
+
|
|
180
|
+
if default_ignores:
|
|
181
|
+
self.checkers[working_directory] = self._parse_ignore_extra(
|
|
182
|
+
working_directory, default_ignores
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
async def read_ignorefile(self, path: str) -> None:
|
|
186
|
+
new_ignore = await self._get_ignored_checker(path)
|
|
187
|
+
|
|
188
|
+
if new_ignore:
|
|
189
|
+
self.checkers[path] = new_ignore
|
|
190
|
+
|
|
191
|
+
def filter(
|
|
192
|
+
self,
|
|
193
|
+
relpaths: list[str],
|
|
194
|
+
root: str,
|
|
195
|
+
) -> list[str]:
|
|
196
|
+
result = []
|
|
197
|
+
|
|
198
|
+
for relpath in relpaths:
|
|
199
|
+
if relpath.startswith(".git"):
|
|
200
|
+
continue
|
|
201
|
+
|
|
202
|
+
path = os.path.join(root, relpath)
|
|
203
|
+
|
|
204
|
+
if self.is_ignored(path):
|
|
205
|
+
continue
|
|
206
|
+
|
|
207
|
+
result.append(relpath)
|
|
208
|
+
|
|
209
|
+
return result
|
|
210
|
+
|
|
211
|
+
def is_ignored(self, path: str) -> bool:
|
|
212
|
+
return any(
|
|
213
|
+
self.checkers[dp](path)
|
|
214
|
+
for dp in self.checkers
|
|
215
|
+
if self._is_subpath(path, dp)
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
def _parse_ignore_extra(
|
|
219
|
+
self, working_directory: str, ignore_extra: list[str]
|
|
220
|
+
) -> Callable[[str], bool]:
|
|
221
|
+
rules: list[IgnoreRule] = []
|
|
222
|
+
|
|
223
|
+
for pattern in ignore_extra:
|
|
224
|
+
if (
|
|
225
|
+
rule := rule_from_pattern(pattern, base_path=working_directory)
|
|
226
|
+
) is not None:
|
|
227
|
+
rules.append(rule)
|
|
228
|
+
|
|
229
|
+
def rule_handler(file_path: str) -> bool:
|
|
230
|
+
nonlocal rules
|
|
231
|
+
return bool(handle_negation(file_path, rules))
|
|
232
|
+
|
|
233
|
+
return rule_handler
|
|
234
|
+
|
|
235
|
+
async def _get_ignored_checker(self, dir_path: str) -> Callable[[str], bool] | None:
|
|
236
|
+
new_ignore = self._parse_gitignore(dir_path)
|
|
237
|
+
|
|
238
|
+
existing_ignore = self.checkers.get(dir_path)
|
|
239
|
+
|
|
240
|
+
if existing_ignore and new_ignore:
|
|
241
|
+
return self._or(new_ignore, existing_ignore)
|
|
242
|
+
|
|
243
|
+
return new_ignore or existing_ignore
|
|
244
|
+
|
|
245
|
+
@staticmethod
|
|
246
|
+
def _parse_gitignore(directory: str) -> Callable[[str], bool] | None:
|
|
247
|
+
gitignore_path = os.path.join(directory, ".gitignore")
|
|
248
|
+
|
|
249
|
+
if os.path.isfile(gitignore_path):
|
|
250
|
+
return cast(Callable[[str], bool], parse_gitignore(gitignore_path))
|
|
251
|
+
|
|
252
|
+
return None
|
|
253
|
+
|
|
254
|
+
@staticmethod
|
|
255
|
+
def _or(
|
|
256
|
+
a: Callable[[str], bool], b: Callable[[str], bool]
|
|
257
|
+
) -> Callable[[str], bool]:
|
|
258
|
+
def or_handler(file_path: str) -> bool:
|
|
259
|
+
return a(file_path) or b(file_path)
|
|
260
|
+
|
|
261
|
+
return or_handler
|
|
262
|
+
|
|
263
|
+
@staticmethod
|
|
264
|
+
def _is_subpath(path: str, parent: str) -> bool:
|
|
265
|
+
"""
|
|
266
|
+
Check if a path is a subpath of another path.
|
|
267
|
+
"""
|
|
268
|
+
return os.path.commonpath([path, parent]) == parent
|