codealmanac 0.1.0.dev0__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.
- codealmanac/__init__.py +13 -0
- codealmanac/app.py +175 -0
- codealmanac/cli/__init__.py +1 -0
- codealmanac/cli/dispatch/__init__.py +0 -0
- codealmanac/cli/dispatch/admin.py +124 -0
- codealmanac/cli/dispatch/config.py +50 -0
- codealmanac/cli/dispatch/root.py +328 -0
- codealmanac/cli/main.py +28 -0
- codealmanac/cli/parser/__init__.py +0 -0
- codealmanac/cli/parser/admin.py +81 -0
- codealmanac/cli/parser/lifecycle.py +57 -0
- codealmanac/cli/parser/root.py +19 -0
- codealmanac/cli/parser/wiki.py +87 -0
- codealmanac/cli/render/__init__.py +0 -0
- codealmanac/cli/render/admin.py +191 -0
- codealmanac/cli/render/root.py +290 -0
- codealmanac/core/__init__.py +1 -0
- codealmanac/core/errors.py +45 -0
- codealmanac/core/models.py +14 -0
- codealmanac/core/paths.py +25 -0
- codealmanac/core/slug.py +7 -0
- codealmanac/core/text.py +5 -0
- codealmanac/database/__init__.py +15 -0
- codealmanac/database/sqlite.py +54 -0
- codealmanac/integrations/__init__.py +1 -0
- codealmanac/integrations/automation/__init__.py +3 -0
- codealmanac/integrations/automation/scheduler/__init__.py +5 -0
- codealmanac/integrations/automation/scheduler/launchd.py +163 -0
- codealmanac/integrations/command.py +56 -0
- codealmanac/integrations/harnesses/__init__.py +7 -0
- codealmanac/integrations/harnesses/claude/__init__.py +1 -0
- codealmanac/integrations/harnesses/claude/adapter.py +217 -0
- codealmanac/integrations/harnesses/codex/__init__.py +3 -0
- codealmanac/integrations/harnesses/codex/adapter.py +221 -0
- codealmanac/integrations/harnesses/git_status.py +49 -0
- codealmanac/integrations/sources/__init__.py +29 -0
- codealmanac/integrations/sources/filesystem/__init__.py +5 -0
- codealmanac/integrations/sources/filesystem/adapter.py +685 -0
- codealmanac/integrations/sources/filesystem/selection.py +209 -0
- codealmanac/integrations/sources/git/__init__.py +3 -0
- codealmanac/integrations/sources/git/adapter.py +132 -0
- codealmanac/integrations/sources/github/__init__.py +3 -0
- codealmanac/integrations/sources/github/adapter.py +413 -0
- codealmanac/integrations/sources/runtime.py +22 -0
- codealmanac/integrations/sources/transcripts/__init__.py +33 -0
- codealmanac/integrations/sources/transcripts/claude.py +61 -0
- codealmanac/integrations/sources/transcripts/codex.py +69 -0
- codealmanac/integrations/sources/transcripts/jsonl.py +84 -0
- codealmanac/integrations/sources/transcripts/runtime.py +387 -0
- codealmanac/integrations/sources/web/__init__.py +3 -0
- codealmanac/integrations/sources/web/adapter.py +303 -0
- codealmanac/integrations/updates/__init__.py +7 -0
- codealmanac/integrations/updates/package.py +85 -0
- codealmanac/integrations/workspaces/__init__.py +1 -0
- codealmanac/integrations/workspaces/git/__init__.py +3 -0
- codealmanac/integrations/workspaces/git/probe.py +128 -0
- codealmanac/manual/README.md +24 -0
- codealmanac/manual/__init__.py +19 -0
- codealmanac/manual/build.md +20 -0
- codealmanac/manual/evidence.md +23 -0
- codealmanac/manual/garden.md +20 -0
- codealmanac/manual/ingest.md +17 -0
- codealmanac/manual/library.py +84 -0
- codealmanac/manual/models.py +83 -0
- codealmanac/manual/pages.md +28 -0
- codealmanac/manual/requests.py +6 -0
- codealmanac/manual/sources.md +18 -0
- codealmanac/manual/style.md +19 -0
- codealmanac/prompts/__init__.py +5 -0
- codealmanac/prompts/base/notability.md +14 -0
- codealmanac/prompts/base/purpose.md +23 -0
- codealmanac/prompts/base/syntax.md +19 -0
- codealmanac/prompts/models.py +9 -0
- codealmanac/prompts/operations/garden.md +26 -0
- codealmanac/prompts/operations/ingest.md +18 -0
- codealmanac/prompts/renderer.py +24 -0
- codealmanac/prompts/requests.py +22 -0
- codealmanac/server/__init__.py +1 -0
- codealmanac/server/app.py +202 -0
- codealmanac/server/assets/__init__.py +1 -0
- codealmanac/server/assets/app.css +865 -0
- codealmanac/server/assets/app.js +3 -0
- codealmanac/server/assets/index.html +80 -0
- codealmanac/server/assets/viewer/api.js +30 -0
- codealmanac/server/assets/viewer/components.js +197 -0
- codealmanac/server/assets/viewer/main.js +126 -0
- codealmanac/server/assets/viewer/renderers.js +122 -0
- codealmanac/server/assets/viewer/routes.js +36 -0
- codealmanac/services/__init__.py +1 -0
- codealmanac/services/automation/__init__.py +3 -0
- codealmanac/services/automation/models.py +83 -0
- codealmanac/services/automation/ports.py +14 -0
- codealmanac/services/automation/requests.py +40 -0
- codealmanac/services/automation/service.py +294 -0
- codealmanac/services/config/__init__.py +17 -0
- codealmanac/services/config/models.py +61 -0
- codealmanac/services/config/requests.py +21 -0
- codealmanac/services/config/service.py +55 -0
- codealmanac/services/config/store.py +26 -0
- codealmanac/services/diagnostics/__init__.py +1 -0
- codealmanac/services/diagnostics/models.py +22 -0
- codealmanac/services/diagnostics/requests.py +8 -0
- codealmanac/services/diagnostics/service.py +283 -0
- codealmanac/services/harnesses/__init__.py +1 -0
- codealmanac/services/harnesses/models.py +104 -0
- codealmanac/services/harnesses/ports.py +18 -0
- codealmanac/services/harnesses/requests.py +19 -0
- codealmanac/services/harnesses/service.py +38 -0
- codealmanac/services/health/__init__.py +1 -0
- codealmanac/services/health/requests.py +8 -0
- codealmanac/services/health/service.py +20 -0
- codealmanac/services/index/__init__.py +1 -0
- codealmanac/services/index/models.py +135 -0
- codealmanac/services/index/requests.py +26 -0
- codealmanac/services/index/service.py +86 -0
- codealmanac/services/index/store.py +411 -0
- codealmanac/services/index/views.py +524 -0
- codealmanac/services/pages/__init__.py +1 -0
- codealmanac/services/pages/requests.py +17 -0
- codealmanac/services/pages/service.py +26 -0
- codealmanac/services/runs/__init__.py +1 -0
- codealmanac/services/runs/models.py +91 -0
- codealmanac/services/runs/requests.py +76 -0
- codealmanac/services/runs/service.py +86 -0
- codealmanac/services/runs/store.py +256 -0
- codealmanac/services/search/__init__.py +1 -0
- codealmanac/services/search/requests.py +23 -0
- codealmanac/services/search/service.py +31 -0
- codealmanac/services/sources/__init__.py +1 -0
- codealmanac/services/sources/models.py +126 -0
- codealmanac/services/sources/ports.py +30 -0
- codealmanac/services/sources/requests.py +76 -0
- codealmanac/services/sources/service.py +351 -0
- codealmanac/services/tagging/__init__.py +1 -0
- codealmanac/services/tagging/models.py +9 -0
- codealmanac/services/tagging/requests.py +35 -0
- codealmanac/services/tagging/service.py +43 -0
- codealmanac/services/topics/__init__.py +1 -0
- codealmanac/services/topics/models.py +36 -0
- codealmanac/services/topics/requests.py +115 -0
- codealmanac/services/topics/service.py +297 -0
- codealmanac/services/updates/__init__.py +4 -0
- codealmanac/services/updates/models.py +83 -0
- codealmanac/services/updates/ports.py +17 -0
- codealmanac/services/updates/requests.py +10 -0
- codealmanac/services/updates/service.py +113 -0
- codealmanac/services/viewer/__init__.py +1 -0
- codealmanac/services/viewer/models.py +80 -0
- codealmanac/services/viewer/renderer.py +89 -0
- codealmanac/services/viewer/requests.py +86 -0
- codealmanac/services/viewer/service.py +211 -0
- codealmanac/services/wiki/__init__.py +1 -0
- codealmanac/services/wiki/documents.py +83 -0
- codealmanac/services/wiki/frontmatter.py +94 -0
- codealmanac/services/wiki/frontmatter_rewrite.py +142 -0
- codealmanac/services/wiki/models.py +69 -0
- codealmanac/services/wiki/paths.py +42 -0
- codealmanac/services/wiki/service.py +57 -0
- codealmanac/services/wiki/templates.py +73 -0
- codealmanac/services/wiki/topics.py +266 -0
- codealmanac/services/wiki/wikilinks.py +58 -0
- codealmanac/services/workspaces/__init__.py +1 -0
- codealmanac/services/workspaces/models.py +124 -0
- codealmanac/services/workspaces/ports.py +9 -0
- codealmanac/services/workspaces/requests.py +82 -0
- codealmanac/services/workspaces/roots.py +74 -0
- codealmanac/services/workspaces/service.py +303 -0
- codealmanac/services/workspaces/store.py +127 -0
- codealmanac/workflows/__init__.py +1 -0
- codealmanac/workflows/build/__init__.py +1 -0
- codealmanac/workflows/build/models.py +8 -0
- codealmanac/workflows/build/service.py +45 -0
- codealmanac/workflows/garden/__init__.py +3 -0
- codealmanac/workflows/garden/models.py +30 -0
- codealmanac/workflows/garden/requests.py +22 -0
- codealmanac/workflows/garden/service.py +239 -0
- codealmanac/workflows/ingest/__init__.py +1 -0
- codealmanac/workflows/ingest/models.py +26 -0
- codealmanac/workflows/ingest/requests.py +39 -0
- codealmanac/workflows/ingest/service.py +302 -0
- codealmanac/workflows/lifecycle.py +197 -0
- codealmanac/workflows/sync/__init__.py +3 -0
- codealmanac/workflows/sync/models.py +157 -0
- codealmanac/workflows/sync/requests.py +63 -0
- codealmanac/workflows/sync/service.py +651 -0
- codealmanac/workflows/sync/store.py +51 -0
- codealmanac-0.1.0.dev0.dist-info/METADATA +248 -0
- codealmanac-0.1.0.dev0.dist-info/RECORD +192 -0
- codealmanac-0.1.0.dev0.dist-info/WHEEL +5 -0
- codealmanac-0.1.0.dev0.dist-info/entry_points.txt +2 -0
- codealmanac-0.1.0.dev0.dist-info/licenses/LICENSE.md +201 -0
- codealmanac-0.1.0.dev0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,685 @@
|
|
|
1
|
+
import subprocess
|
|
2
|
+
from collections.abc import Iterator
|
|
3
|
+
from contextlib import suppress
|
|
4
|
+
from enum import StrEnum
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from charset_normalizer import from_bytes
|
|
8
|
+
from pathspec.gitignore import GitIgnoreSpec
|
|
9
|
+
from pydantic import field_validator
|
|
10
|
+
|
|
11
|
+
from codealmanac.core.models import CodeAlmanacModel
|
|
12
|
+
from codealmanac.core.text import required_text
|
|
13
|
+
from codealmanac.integrations.command import (
|
|
14
|
+
CommandRunner,
|
|
15
|
+
SubprocessCommandRunner,
|
|
16
|
+
first_line,
|
|
17
|
+
)
|
|
18
|
+
from codealmanac.integrations.sources.filesystem.selection import (
|
|
19
|
+
FilesystemDirectoryCandidate,
|
|
20
|
+
FilesystemDirectoryFileState,
|
|
21
|
+
FilesystemDirectoryListingSource,
|
|
22
|
+
FilesystemDirectorySelectionPolicy,
|
|
23
|
+
directory_selection_group,
|
|
24
|
+
ranked_directory_candidates,
|
|
25
|
+
)
|
|
26
|
+
from codealmanac.integrations.sources.runtime import (
|
|
27
|
+
bounded_text,
|
|
28
|
+
source_runtime_section,
|
|
29
|
+
)
|
|
30
|
+
from codealmanac.services.sources.models import (
|
|
31
|
+
SourceKind,
|
|
32
|
+
SourceRef,
|
|
33
|
+
SourceRuntime,
|
|
34
|
+
SourceRuntimeStatus,
|
|
35
|
+
)
|
|
36
|
+
from codealmanac.services.sources.requests import InspectSourceRuntimeRequest
|
|
37
|
+
|
|
38
|
+
DEFAULT_MAX_FILE_BYTES = 200_000
|
|
39
|
+
DEFAULT_MAX_DIRECTORY_FILES = 25
|
|
40
|
+
DEFAULT_MAX_CHARS = 60_000
|
|
41
|
+
GIT_DIRECTORY_LIST_TIMEOUT_SECONDS = 10
|
|
42
|
+
DEFAULT_IGNORE_PATTERNS = (
|
|
43
|
+
".git/",
|
|
44
|
+
"node_modules/",
|
|
45
|
+
".venv/",
|
|
46
|
+
"venv/",
|
|
47
|
+
"__pycache__/",
|
|
48
|
+
".mypy_cache/",
|
|
49
|
+
".pytest_cache/",
|
|
50
|
+
".ruff_cache/",
|
|
51
|
+
".gitignore",
|
|
52
|
+
".env",
|
|
53
|
+
".env.*",
|
|
54
|
+
"*.pyc",
|
|
55
|
+
".DS_Store",
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class FilesystemRuntimeKind(StrEnum):
|
|
60
|
+
FILE = "file"
|
|
61
|
+
DIRECTORY = "directory"
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class FilesystemTextDocument(CodeAlmanacModel):
|
|
65
|
+
path: Path
|
|
66
|
+
display_path: str
|
|
67
|
+
size_bytes: int
|
|
68
|
+
encoding: str
|
|
69
|
+
text: str
|
|
70
|
+
selection_state: FilesystemDirectoryFileState = (
|
|
71
|
+
FilesystemDirectoryFileState.UNCHANGED
|
|
72
|
+
)
|
|
73
|
+
git_status: str | None = None
|
|
74
|
+
bytes_truncated: bool = False
|
|
75
|
+
|
|
76
|
+
@field_validator("display_path", "encoding", "text")
|
|
77
|
+
@classmethod
|
|
78
|
+
def require_text_fields(cls, value: str) -> str:
|
|
79
|
+
return required_text(value, "filesystem runtime document")
|
|
80
|
+
|
|
81
|
+
@field_validator("size_bytes")
|
|
82
|
+
@classmethod
|
|
83
|
+
def non_negative_size(cls, value: int) -> int:
|
|
84
|
+
if value < 0:
|
|
85
|
+
raise ValueError("filesystem runtime file size must be non-negative")
|
|
86
|
+
return value
|
|
87
|
+
|
|
88
|
+
@field_validator("git_status")
|
|
89
|
+
@classmethod
|
|
90
|
+
def validate_git_status(cls, value: str | None) -> str | None:
|
|
91
|
+
if value is None:
|
|
92
|
+
return value
|
|
93
|
+
if len(value) != 2:
|
|
94
|
+
raise ValueError("filesystem runtime git status must be two characters")
|
|
95
|
+
return value
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class FilesystemDirectoryDocument(CodeAlmanacModel):
|
|
99
|
+
path: Path
|
|
100
|
+
display_path: str
|
|
101
|
+
listing_source: FilesystemDirectoryListingSource
|
|
102
|
+
selection_policy: FilesystemDirectorySelectionPolicy
|
|
103
|
+
changed_count: int = 0
|
|
104
|
+
files: tuple[FilesystemTextDocument, ...]
|
|
105
|
+
skipped_count: int = 0
|
|
106
|
+
file_list_truncated: bool = False
|
|
107
|
+
|
|
108
|
+
@field_validator("display_path")
|
|
109
|
+
@classmethod
|
|
110
|
+
def require_display_path(cls, value: str) -> str:
|
|
111
|
+
return required_text(value, "filesystem runtime directory")
|
|
112
|
+
|
|
113
|
+
@field_validator("skipped_count")
|
|
114
|
+
@classmethod
|
|
115
|
+
def non_negative_skipped_count(cls, value: int) -> int:
|
|
116
|
+
if value < 0:
|
|
117
|
+
raise ValueError("filesystem runtime skipped count must be non-negative")
|
|
118
|
+
return value
|
|
119
|
+
|
|
120
|
+
@field_validator("changed_count")
|
|
121
|
+
@classmethod
|
|
122
|
+
def non_negative_changed_count(cls, value: int) -> int:
|
|
123
|
+
if value < 0:
|
|
124
|
+
raise ValueError("filesystem runtime changed count must be non-negative")
|
|
125
|
+
return value
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
class UnreadableTextError(Exception):
|
|
129
|
+
pass
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
class FilesystemSourceRuntimeAdapter:
|
|
133
|
+
def __init__(
|
|
134
|
+
self,
|
|
135
|
+
runner: CommandRunner | None = None,
|
|
136
|
+
max_file_bytes: int = DEFAULT_MAX_FILE_BYTES,
|
|
137
|
+
max_directory_files: int = DEFAULT_MAX_DIRECTORY_FILES,
|
|
138
|
+
max_chars: int = DEFAULT_MAX_CHARS,
|
|
139
|
+
git_timeout_seconds: int = GIT_DIRECTORY_LIST_TIMEOUT_SECONDS,
|
|
140
|
+
):
|
|
141
|
+
self.runner = runner or SubprocessCommandRunner()
|
|
142
|
+
self.max_file_bytes = max_file_bytes
|
|
143
|
+
self.max_directory_files = max_directory_files
|
|
144
|
+
self.max_chars = max_chars
|
|
145
|
+
self.git_timeout_seconds = git_timeout_seconds
|
|
146
|
+
|
|
147
|
+
def supports(self, ref: SourceRef) -> bool:
|
|
148
|
+
return ref.kind in {
|
|
149
|
+
SourceKind.PATH_FILE,
|
|
150
|
+
SourceKind.PATH_DIRECTORY,
|
|
151
|
+
SourceKind.PATH_UNKNOWN,
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
def inspect(self, request: InspectSourceRuntimeRequest) -> SourceRuntime:
|
|
155
|
+
if request.ref.kind == SourceKind.PATH_FILE:
|
|
156
|
+
return self._inspect_file(request.cwd, request.ref)
|
|
157
|
+
if request.ref.kind == SourceKind.PATH_DIRECTORY:
|
|
158
|
+
return self._inspect_directory(
|
|
159
|
+
request.cwd,
|
|
160
|
+
request.ref,
|
|
161
|
+
request.context.ignored_directories,
|
|
162
|
+
)
|
|
163
|
+
if request.ref.kind == SourceKind.PATH_UNKNOWN:
|
|
164
|
+
return unavailable_runtime(
|
|
165
|
+
request.ref,
|
|
166
|
+
"Path unavailable",
|
|
167
|
+
missing_path_diagnostic(request.ref),
|
|
168
|
+
)
|
|
169
|
+
return SourceRuntime(
|
|
170
|
+
ref=request.ref,
|
|
171
|
+
status=SourceRuntimeStatus.SKIPPED,
|
|
172
|
+
title=f"Unsupported filesystem source {request.ref.identity}",
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
def _inspect_file(self, cwd: Path, ref: SourceRef) -> SourceRuntime:
|
|
176
|
+
path = ref.path
|
|
177
|
+
if path is None:
|
|
178
|
+
return unavailable_runtime(
|
|
179
|
+
ref,
|
|
180
|
+
"File unavailable",
|
|
181
|
+
"file source requires a path",
|
|
182
|
+
)
|
|
183
|
+
if not path.is_file():
|
|
184
|
+
return unavailable_runtime(
|
|
185
|
+
ref,
|
|
186
|
+
"File unavailable",
|
|
187
|
+
f"path not found: {path}",
|
|
188
|
+
)
|
|
189
|
+
try:
|
|
190
|
+
document = read_text_document(path, cwd, self.max_file_bytes)
|
|
191
|
+
except (OSError, UnreadableTextError) as error:
|
|
192
|
+
return unavailable_runtime(ref, "File unavailable", first_error_line(error))
|
|
193
|
+
content, truncated = bounded_text(
|
|
194
|
+
"\n\n".join(
|
|
195
|
+
(
|
|
196
|
+
source_runtime_section(
|
|
197
|
+
"metadata",
|
|
198
|
+
render_file_metadata(document),
|
|
199
|
+
),
|
|
200
|
+
source_runtime_section("content", document.text),
|
|
201
|
+
)
|
|
202
|
+
),
|
|
203
|
+
self.max_chars,
|
|
204
|
+
)
|
|
205
|
+
return SourceRuntime(
|
|
206
|
+
ref=ref,
|
|
207
|
+
status=SourceRuntimeStatus.AVAILABLE,
|
|
208
|
+
title=f"File {document.display_path}",
|
|
209
|
+
content=content,
|
|
210
|
+
truncated=truncated or document.bytes_truncated,
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
def _inspect_directory(
|
|
214
|
+
self,
|
|
215
|
+
cwd: Path,
|
|
216
|
+
ref: SourceRef,
|
|
217
|
+
ignored_directories: tuple[Path, ...],
|
|
218
|
+
) -> SourceRuntime:
|
|
219
|
+
path = ref.path
|
|
220
|
+
if path is None:
|
|
221
|
+
return unavailable_runtime(
|
|
222
|
+
ref,
|
|
223
|
+
"Directory unavailable",
|
|
224
|
+
"directory source requires a path",
|
|
225
|
+
)
|
|
226
|
+
if not path.is_dir():
|
|
227
|
+
return unavailable_runtime(
|
|
228
|
+
ref,
|
|
229
|
+
"Directory unavailable",
|
|
230
|
+
f"path not found: {path}",
|
|
231
|
+
)
|
|
232
|
+
document = read_directory_document(
|
|
233
|
+
path,
|
|
234
|
+
cwd,
|
|
235
|
+
self.max_file_bytes,
|
|
236
|
+
self.max_directory_files,
|
|
237
|
+
self.runner,
|
|
238
|
+
self.git_timeout_seconds,
|
|
239
|
+
ignored_directories,
|
|
240
|
+
)
|
|
241
|
+
content, truncated = bounded_text(
|
|
242
|
+
"\n\n".join(
|
|
243
|
+
(
|
|
244
|
+
source_runtime_section(
|
|
245
|
+
"metadata",
|
|
246
|
+
render_directory_metadata(document),
|
|
247
|
+
),
|
|
248
|
+
source_runtime_section("tree", render_tree(document.files)),
|
|
249
|
+
source_runtime_section("files", render_directory_files(document)),
|
|
250
|
+
)
|
|
251
|
+
),
|
|
252
|
+
self.max_chars,
|
|
253
|
+
)
|
|
254
|
+
return SourceRuntime(
|
|
255
|
+
ref=ref,
|
|
256
|
+
status=SourceRuntimeStatus.AVAILABLE,
|
|
257
|
+
title=f"Directory {document.display_path}",
|
|
258
|
+
content=content,
|
|
259
|
+
truncated=(
|
|
260
|
+
truncated
|
|
261
|
+
or document.file_list_truncated
|
|
262
|
+
or any(file.bytes_truncated for file in document.files)
|
|
263
|
+
),
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def read_text_document(
|
|
268
|
+
path: Path,
|
|
269
|
+
cwd: Path,
|
|
270
|
+
max_file_bytes: int,
|
|
271
|
+
selection_state: FilesystemDirectoryFileState = (
|
|
272
|
+
FilesystemDirectoryFileState.UNCHANGED
|
|
273
|
+
),
|
|
274
|
+
git_status: str | None = None,
|
|
275
|
+
) -> FilesystemTextDocument:
|
|
276
|
+
size_bytes = path.stat().st_size
|
|
277
|
+
with path.open("rb") as file:
|
|
278
|
+
raw = file.read(max_file_bytes + 1)
|
|
279
|
+
bytes_truncated = len(raw) > max_file_bytes
|
|
280
|
+
if bytes_truncated:
|
|
281
|
+
raw = raw[:max_file_bytes]
|
|
282
|
+
if len(raw) == 0:
|
|
283
|
+
return FilesystemTextDocument(
|
|
284
|
+
path=path,
|
|
285
|
+
display_path=display_path(path, cwd),
|
|
286
|
+
size_bytes=size_bytes,
|
|
287
|
+
encoding="utf-8",
|
|
288
|
+
text="(empty file)",
|
|
289
|
+
selection_state=selection_state,
|
|
290
|
+
git_status=git_status,
|
|
291
|
+
bytes_truncated=False,
|
|
292
|
+
)
|
|
293
|
+
match = from_bytes(raw).best()
|
|
294
|
+
if match is None:
|
|
295
|
+
raise UnreadableTextError(
|
|
296
|
+
f"file is not readable text: {display_path(path, cwd)}"
|
|
297
|
+
)
|
|
298
|
+
text = str(match)
|
|
299
|
+
if text.strip() == "":
|
|
300
|
+
text = "(empty file)"
|
|
301
|
+
return FilesystemTextDocument(
|
|
302
|
+
path=path,
|
|
303
|
+
display_path=display_path(path, cwd),
|
|
304
|
+
size_bytes=size_bytes,
|
|
305
|
+
encoding=match.encoding,
|
|
306
|
+
text=text,
|
|
307
|
+
selection_state=selection_state,
|
|
308
|
+
git_status=git_status,
|
|
309
|
+
bytes_truncated=bytes_truncated,
|
|
310
|
+
)
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def read_directory_document(
|
|
314
|
+
root: Path,
|
|
315
|
+
cwd: Path,
|
|
316
|
+
max_file_bytes: int,
|
|
317
|
+
max_directory_files: int,
|
|
318
|
+
runner: CommandRunner,
|
|
319
|
+
git_timeout_seconds: int,
|
|
320
|
+
ignored_directories: tuple[Path, ...],
|
|
321
|
+
) -> FilesystemDirectoryDocument:
|
|
322
|
+
ignore_spec = ignore_spec_for(root, cwd, ignored_directories)
|
|
323
|
+
listing_source = FilesystemDirectoryListingSource.WALK
|
|
324
|
+
selection_policy = FilesystemDirectorySelectionPolicy.DIVERSE
|
|
325
|
+
candidates = ranked_directory_candidates(
|
|
326
|
+
tuple(walk_file_candidates(root, cwd, ignore_spec))
|
|
327
|
+
)
|
|
328
|
+
git_candidates = git_directory_candidates(
|
|
329
|
+
root,
|
|
330
|
+
cwd,
|
|
331
|
+
runner,
|
|
332
|
+
git_timeout_seconds,
|
|
333
|
+
ignore_spec,
|
|
334
|
+
)
|
|
335
|
+
if git_candidates is not None:
|
|
336
|
+
listing_source = FilesystemDirectoryListingSource.GIT
|
|
337
|
+
selection_policy = FilesystemDirectorySelectionPolicy.CHANGED_THEN_DIVERSE
|
|
338
|
+
candidates = git_candidates
|
|
339
|
+
files: list[FilesystemTextDocument] = []
|
|
340
|
+
skipped_count = 0
|
|
341
|
+
file_list_truncated = False
|
|
342
|
+
changed_count = sum(
|
|
343
|
+
1
|
|
344
|
+
for candidate in candidates
|
|
345
|
+
if candidate.state == FilesystemDirectoryFileState.CHANGED
|
|
346
|
+
)
|
|
347
|
+
for candidate in candidates:
|
|
348
|
+
if len(files) >= max_directory_files:
|
|
349
|
+
file_list_truncated = True
|
|
350
|
+
break
|
|
351
|
+
try:
|
|
352
|
+
files.append(
|
|
353
|
+
read_text_document(
|
|
354
|
+
candidate.path,
|
|
355
|
+
cwd,
|
|
356
|
+
max_file_bytes,
|
|
357
|
+
candidate.state,
|
|
358
|
+
candidate.git_status,
|
|
359
|
+
)
|
|
360
|
+
)
|
|
361
|
+
except (OSError, UnreadableTextError):
|
|
362
|
+
skipped_count += 1
|
|
363
|
+
return FilesystemDirectoryDocument(
|
|
364
|
+
path=root,
|
|
365
|
+
display_path=display_path(root, cwd),
|
|
366
|
+
listing_source=listing_source,
|
|
367
|
+
selection_policy=selection_policy,
|
|
368
|
+
changed_count=changed_count,
|
|
369
|
+
files=tuple(files),
|
|
370
|
+
skipped_count=skipped_count,
|
|
371
|
+
file_list_truncated=file_list_truncated,
|
|
372
|
+
)
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def walk_file_candidates(
|
|
376
|
+
root: Path,
|
|
377
|
+
cwd: Path,
|
|
378
|
+
ignore_spec: GitIgnoreSpec,
|
|
379
|
+
) -> Iterator[FilesystemDirectoryCandidate]:
|
|
380
|
+
for path in walk_files(root, cwd, ignore_spec):
|
|
381
|
+
yield FilesystemDirectoryCandidate(
|
|
382
|
+
path=path,
|
|
383
|
+
display_path=display_path(path, cwd),
|
|
384
|
+
selection_group=directory_selection_group(path, root),
|
|
385
|
+
)
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def walk_files(
|
|
389
|
+
root: Path,
|
|
390
|
+
cwd: Path,
|
|
391
|
+
ignore_spec: GitIgnoreSpec,
|
|
392
|
+
) -> Iterator[Path]:
|
|
393
|
+
for child in sorted_children(root):
|
|
394
|
+
if should_skip_path(child, cwd, root, ignore_spec):
|
|
395
|
+
continue
|
|
396
|
+
if child.is_symlink():
|
|
397
|
+
continue
|
|
398
|
+
if child.is_dir():
|
|
399
|
+
yield from walk_files(child, cwd, ignore_spec)
|
|
400
|
+
continue
|
|
401
|
+
if child.is_file():
|
|
402
|
+
yield child
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
def sorted_children(path: Path) -> tuple[Path, ...]:
|
|
406
|
+
try:
|
|
407
|
+
return tuple(sorted(path.iterdir(), key=lambda child: child.name.casefold()))
|
|
408
|
+
except OSError:
|
|
409
|
+
return ()
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
def git_directory_candidates(
|
|
413
|
+
root: Path,
|
|
414
|
+
cwd: Path,
|
|
415
|
+
runner: CommandRunner,
|
|
416
|
+
timeout_seconds: int,
|
|
417
|
+
ignore_spec: GitIgnoreSpec,
|
|
418
|
+
) -> tuple[FilesystemDirectoryCandidate, ...] | None:
|
|
419
|
+
repo_root = git_repo_root(root, runner, timeout_seconds)
|
|
420
|
+
if repo_root is None:
|
|
421
|
+
return None
|
|
422
|
+
try:
|
|
423
|
+
pathspec = root.relative_to(repo_root).as_posix()
|
|
424
|
+
except ValueError:
|
|
425
|
+
return None
|
|
426
|
+
if pathspec == ".":
|
|
427
|
+
pathspec = "."
|
|
428
|
+
result = run_git(
|
|
429
|
+
runner,
|
|
430
|
+
repo_root,
|
|
431
|
+
(
|
|
432
|
+
"ls-files",
|
|
433
|
+
"-z",
|
|
434
|
+
"--cached",
|
|
435
|
+
"--others",
|
|
436
|
+
"--exclude-standard",
|
|
437
|
+
"--full-name",
|
|
438
|
+
"--",
|
|
439
|
+
pathspec,
|
|
440
|
+
),
|
|
441
|
+
timeout_seconds,
|
|
442
|
+
)
|
|
443
|
+
if result is None:
|
|
444
|
+
return None
|
|
445
|
+
changed_statuses = git_changed_statuses(
|
|
446
|
+
repo_root,
|
|
447
|
+
root,
|
|
448
|
+
pathspec,
|
|
449
|
+
runner,
|
|
450
|
+
timeout_seconds,
|
|
451
|
+
)
|
|
452
|
+
candidates: list[FilesystemDirectoryCandidate] = []
|
|
453
|
+
seen: set[Path] = set()
|
|
454
|
+
for value in result.stdout.split("\0"):
|
|
455
|
+
if not value:
|
|
456
|
+
continue
|
|
457
|
+
path = repo_root / value
|
|
458
|
+
if path in seen:
|
|
459
|
+
continue
|
|
460
|
+
if not is_relative_to(path, root):
|
|
461
|
+
continue
|
|
462
|
+
if should_skip_path(path, cwd, root, ignore_spec):
|
|
463
|
+
continue
|
|
464
|
+
if path.is_file():
|
|
465
|
+
seen.add(path)
|
|
466
|
+
git_status = changed_statuses.get(path)
|
|
467
|
+
state = FilesystemDirectoryFileState.UNCHANGED
|
|
468
|
+
if git_status is not None:
|
|
469
|
+
state = FilesystemDirectoryFileState.CHANGED
|
|
470
|
+
candidates.append(
|
|
471
|
+
FilesystemDirectoryCandidate(
|
|
472
|
+
path=path,
|
|
473
|
+
display_path=display_path(path, cwd),
|
|
474
|
+
selection_group=directory_selection_group(path, root),
|
|
475
|
+
state=state,
|
|
476
|
+
git_status=git_status,
|
|
477
|
+
)
|
|
478
|
+
)
|
|
479
|
+
return ranked_directory_candidates(tuple(candidates))
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
def git_changed_statuses(
|
|
483
|
+
repo_root: Path,
|
|
484
|
+
root: Path,
|
|
485
|
+
pathspec: str,
|
|
486
|
+
runner: CommandRunner,
|
|
487
|
+
timeout_seconds: int,
|
|
488
|
+
) -> dict[Path, str]:
|
|
489
|
+
result = run_git(
|
|
490
|
+
runner,
|
|
491
|
+
repo_root,
|
|
492
|
+
(
|
|
493
|
+
"--no-optional-locks",
|
|
494
|
+
"status",
|
|
495
|
+
"--porcelain=v1",
|
|
496
|
+
"-z",
|
|
497
|
+
"--untracked-files=all",
|
|
498
|
+
"--",
|
|
499
|
+
pathspec,
|
|
500
|
+
),
|
|
501
|
+
timeout_seconds,
|
|
502
|
+
)
|
|
503
|
+
if result is None:
|
|
504
|
+
return {}
|
|
505
|
+
statuses: dict[Path, str] = {}
|
|
506
|
+
for relative_path, status in parse_git_status_z(result.stdout):
|
|
507
|
+
path = repo_root / relative_path
|
|
508
|
+
if is_relative_to(path, root):
|
|
509
|
+
statuses[path] = status
|
|
510
|
+
return statuses
|
|
511
|
+
|
|
512
|
+
|
|
513
|
+
def parse_git_status_z(stdout: str) -> tuple[tuple[str, str], ...]:
|
|
514
|
+
parts = stdout.split("\0")
|
|
515
|
+
parsed: list[tuple[str, str]] = []
|
|
516
|
+
index = 0
|
|
517
|
+
while index < len(parts):
|
|
518
|
+
entry = parts[index]
|
|
519
|
+
index += 1
|
|
520
|
+
if not entry or len(entry) < 4 or entry[2] != " ":
|
|
521
|
+
continue
|
|
522
|
+
status = entry[:2]
|
|
523
|
+
relative_path = entry[3:]
|
|
524
|
+
parsed.append((relative_path, status))
|
|
525
|
+
if "R" in status or "C" in status:
|
|
526
|
+
index += 1
|
|
527
|
+
return tuple(parsed)
|
|
528
|
+
|
|
529
|
+
|
|
530
|
+
def git_repo_root(
|
|
531
|
+
root: Path,
|
|
532
|
+
runner: CommandRunner,
|
|
533
|
+
timeout_seconds: int,
|
|
534
|
+
) -> Path | None:
|
|
535
|
+
result = run_git(runner, root, ("rev-parse", "--show-toplevel"), timeout_seconds)
|
|
536
|
+
if result is None:
|
|
537
|
+
return None
|
|
538
|
+
text = first_line(result.stdout)
|
|
539
|
+
if not text:
|
|
540
|
+
return None
|
|
541
|
+
repo_root = Path(text).expanduser().resolve(strict=False)
|
|
542
|
+
if not is_relative_to(root, repo_root):
|
|
543
|
+
return None
|
|
544
|
+
return repo_root
|
|
545
|
+
|
|
546
|
+
|
|
547
|
+
def run_git(
|
|
548
|
+
runner: CommandRunner,
|
|
549
|
+
cwd: Path,
|
|
550
|
+
args: tuple[str, ...],
|
|
551
|
+
timeout_seconds: int,
|
|
552
|
+
):
|
|
553
|
+
try:
|
|
554
|
+
result = runner.run("git", args, cwd, timeout_seconds)
|
|
555
|
+
except (FileNotFoundError, OSError, subprocess.TimeoutExpired):
|
|
556
|
+
return None
|
|
557
|
+
if result.returncode != 0:
|
|
558
|
+
return None
|
|
559
|
+
return result
|
|
560
|
+
|
|
561
|
+
|
|
562
|
+
def should_skip_path(
|
|
563
|
+
path: Path,
|
|
564
|
+
cwd: Path,
|
|
565
|
+
root: Path,
|
|
566
|
+
ignore_spec: GitIgnoreSpec,
|
|
567
|
+
) -> bool:
|
|
568
|
+
return ignore_spec.match_file(ignore_key(path, cwd, root))
|
|
569
|
+
|
|
570
|
+
|
|
571
|
+
def ignore_key(path: Path, cwd: Path, root: Path) -> str:
|
|
572
|
+
base = cwd if is_relative_to(path, cwd) else root
|
|
573
|
+
return path.relative_to(base).as_posix()
|
|
574
|
+
|
|
575
|
+
|
|
576
|
+
def ignore_spec_for(
|
|
577
|
+
root: Path,
|
|
578
|
+
cwd: Path,
|
|
579
|
+
ignored_directories: tuple[Path, ...],
|
|
580
|
+
) -> GitIgnoreSpec:
|
|
581
|
+
lines = list(DEFAULT_IGNORE_PATTERNS)
|
|
582
|
+
lines.extend(ignored_directory_patterns(ignored_directories))
|
|
583
|
+
gitignore = cwd / ".gitignore" if is_relative_to(root, cwd) else root / ".gitignore"
|
|
584
|
+
if gitignore.is_file():
|
|
585
|
+
with suppress(OSError):
|
|
586
|
+
lines.extend(gitignore.read_text(encoding="utf-8").splitlines())
|
|
587
|
+
return GitIgnoreSpec.from_lines(lines)
|
|
588
|
+
|
|
589
|
+
|
|
590
|
+
def ignored_directory_patterns(
|
|
591
|
+
ignored_directories: tuple[Path, ...],
|
|
592
|
+
) -> tuple[str, ...]:
|
|
593
|
+
return tuple(
|
|
594
|
+
f"{directory.as_posix().rstrip('/')}/" for directory in ignored_directories
|
|
595
|
+
)
|
|
596
|
+
|
|
597
|
+
|
|
598
|
+
def render_file_metadata(document: FilesystemTextDocument) -> str:
|
|
599
|
+
return "\n".join(
|
|
600
|
+
(
|
|
601
|
+
f"kind: {FilesystemRuntimeKind.FILE.value}",
|
|
602
|
+
f"path: {document.display_path}",
|
|
603
|
+
f"size_bytes: {document.size_bytes}",
|
|
604
|
+
f"encoding: {document.encoding}",
|
|
605
|
+
f"bytes_truncated: {str(document.bytes_truncated).lower()}",
|
|
606
|
+
)
|
|
607
|
+
)
|
|
608
|
+
|
|
609
|
+
|
|
610
|
+
def render_directory_metadata(document: FilesystemDirectoryDocument) -> str:
|
|
611
|
+
return "\n".join(
|
|
612
|
+
(
|
|
613
|
+
f"kind: {FilesystemRuntimeKind.DIRECTORY.value}",
|
|
614
|
+
f"path: {document.display_path}",
|
|
615
|
+
f"listing_source: {document.listing_source.value}",
|
|
616
|
+
f"selection_policy: {document.selection_policy.value}",
|
|
617
|
+
f"files_included: {len(document.files)}",
|
|
618
|
+
f"changed_files_available: {document.changed_count}",
|
|
619
|
+
f"files_skipped: {document.skipped_count}",
|
|
620
|
+
f"file_list_truncated: {str(document.file_list_truncated).lower()}",
|
|
621
|
+
)
|
|
622
|
+
)
|
|
623
|
+
|
|
624
|
+
|
|
625
|
+
def render_tree(files: tuple[FilesystemTextDocument, ...]) -> str:
|
|
626
|
+
if len(files) == 0:
|
|
627
|
+
return "(no readable files)"
|
|
628
|
+
return "\n".join(
|
|
629
|
+
(
|
|
630
|
+
f"- {file.display_path} [{file.selection_state.value}] "
|
|
631
|
+
f"({file.size_bytes} bytes, {file.encoding})"
|
|
632
|
+
)
|
|
633
|
+
for file in files
|
|
634
|
+
)
|
|
635
|
+
|
|
636
|
+
|
|
637
|
+
def render_directory_files(document: FilesystemDirectoryDocument) -> str:
|
|
638
|
+
if len(document.files) == 0:
|
|
639
|
+
return "(no readable files)"
|
|
640
|
+
return "\n\n".join(
|
|
641
|
+
f"### {file.display_path}\n\n{file.text}" for file in document.files
|
|
642
|
+
)
|
|
643
|
+
|
|
644
|
+
|
|
645
|
+
def missing_path_diagnostic(ref: SourceRef) -> str:
|
|
646
|
+
if ref.path is None:
|
|
647
|
+
return "path source requires a path"
|
|
648
|
+
return f"path not found: {ref.path}"
|
|
649
|
+
|
|
650
|
+
|
|
651
|
+
def unavailable_runtime(
|
|
652
|
+
ref: SourceRef,
|
|
653
|
+
title: str,
|
|
654
|
+
diagnostic: str,
|
|
655
|
+
) -> SourceRuntime:
|
|
656
|
+
return SourceRuntime(
|
|
657
|
+
ref=ref,
|
|
658
|
+
status=SourceRuntimeStatus.UNAVAILABLE,
|
|
659
|
+
title=title,
|
|
660
|
+
diagnostics=(diagnostic,),
|
|
661
|
+
)
|
|
662
|
+
|
|
663
|
+
|
|
664
|
+
def first_error_line(error: Exception) -> str:
|
|
665
|
+
lines = [line.strip() for line in str(error).splitlines() if line.strip()]
|
|
666
|
+
if len(lines) == 0:
|
|
667
|
+
return error.__class__.__name__
|
|
668
|
+
return lines[0]
|
|
669
|
+
|
|
670
|
+
|
|
671
|
+
def display_path(path: Path, cwd: Path) -> str:
|
|
672
|
+
if is_relative_to(path, cwd):
|
|
673
|
+
relative = path.relative_to(cwd)
|
|
674
|
+
if str(relative) == ".":
|
|
675
|
+
return "."
|
|
676
|
+
return relative.as_posix()
|
|
677
|
+
return str(path)
|
|
678
|
+
|
|
679
|
+
|
|
680
|
+
def is_relative_to(path: Path, base: Path) -> bool:
|
|
681
|
+
try:
|
|
682
|
+
path.relative_to(base)
|
|
683
|
+
except ValueError:
|
|
684
|
+
return False
|
|
685
|
+
return True
|