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.
Files changed (192) hide show
  1. codealmanac/__init__.py +13 -0
  2. codealmanac/app.py +175 -0
  3. codealmanac/cli/__init__.py +1 -0
  4. codealmanac/cli/dispatch/__init__.py +0 -0
  5. codealmanac/cli/dispatch/admin.py +124 -0
  6. codealmanac/cli/dispatch/config.py +50 -0
  7. codealmanac/cli/dispatch/root.py +328 -0
  8. codealmanac/cli/main.py +28 -0
  9. codealmanac/cli/parser/__init__.py +0 -0
  10. codealmanac/cli/parser/admin.py +81 -0
  11. codealmanac/cli/parser/lifecycle.py +57 -0
  12. codealmanac/cli/parser/root.py +19 -0
  13. codealmanac/cli/parser/wiki.py +87 -0
  14. codealmanac/cli/render/__init__.py +0 -0
  15. codealmanac/cli/render/admin.py +191 -0
  16. codealmanac/cli/render/root.py +290 -0
  17. codealmanac/core/__init__.py +1 -0
  18. codealmanac/core/errors.py +45 -0
  19. codealmanac/core/models.py +14 -0
  20. codealmanac/core/paths.py +25 -0
  21. codealmanac/core/slug.py +7 -0
  22. codealmanac/core/text.py +5 -0
  23. codealmanac/database/__init__.py +15 -0
  24. codealmanac/database/sqlite.py +54 -0
  25. codealmanac/integrations/__init__.py +1 -0
  26. codealmanac/integrations/automation/__init__.py +3 -0
  27. codealmanac/integrations/automation/scheduler/__init__.py +5 -0
  28. codealmanac/integrations/automation/scheduler/launchd.py +163 -0
  29. codealmanac/integrations/command.py +56 -0
  30. codealmanac/integrations/harnesses/__init__.py +7 -0
  31. codealmanac/integrations/harnesses/claude/__init__.py +1 -0
  32. codealmanac/integrations/harnesses/claude/adapter.py +217 -0
  33. codealmanac/integrations/harnesses/codex/__init__.py +3 -0
  34. codealmanac/integrations/harnesses/codex/adapter.py +221 -0
  35. codealmanac/integrations/harnesses/git_status.py +49 -0
  36. codealmanac/integrations/sources/__init__.py +29 -0
  37. codealmanac/integrations/sources/filesystem/__init__.py +5 -0
  38. codealmanac/integrations/sources/filesystem/adapter.py +685 -0
  39. codealmanac/integrations/sources/filesystem/selection.py +209 -0
  40. codealmanac/integrations/sources/git/__init__.py +3 -0
  41. codealmanac/integrations/sources/git/adapter.py +132 -0
  42. codealmanac/integrations/sources/github/__init__.py +3 -0
  43. codealmanac/integrations/sources/github/adapter.py +413 -0
  44. codealmanac/integrations/sources/runtime.py +22 -0
  45. codealmanac/integrations/sources/transcripts/__init__.py +33 -0
  46. codealmanac/integrations/sources/transcripts/claude.py +61 -0
  47. codealmanac/integrations/sources/transcripts/codex.py +69 -0
  48. codealmanac/integrations/sources/transcripts/jsonl.py +84 -0
  49. codealmanac/integrations/sources/transcripts/runtime.py +387 -0
  50. codealmanac/integrations/sources/web/__init__.py +3 -0
  51. codealmanac/integrations/sources/web/adapter.py +303 -0
  52. codealmanac/integrations/updates/__init__.py +7 -0
  53. codealmanac/integrations/updates/package.py +85 -0
  54. codealmanac/integrations/workspaces/__init__.py +1 -0
  55. codealmanac/integrations/workspaces/git/__init__.py +3 -0
  56. codealmanac/integrations/workspaces/git/probe.py +128 -0
  57. codealmanac/manual/README.md +24 -0
  58. codealmanac/manual/__init__.py +19 -0
  59. codealmanac/manual/build.md +20 -0
  60. codealmanac/manual/evidence.md +23 -0
  61. codealmanac/manual/garden.md +20 -0
  62. codealmanac/manual/ingest.md +17 -0
  63. codealmanac/manual/library.py +84 -0
  64. codealmanac/manual/models.py +83 -0
  65. codealmanac/manual/pages.md +28 -0
  66. codealmanac/manual/requests.py +6 -0
  67. codealmanac/manual/sources.md +18 -0
  68. codealmanac/manual/style.md +19 -0
  69. codealmanac/prompts/__init__.py +5 -0
  70. codealmanac/prompts/base/notability.md +14 -0
  71. codealmanac/prompts/base/purpose.md +23 -0
  72. codealmanac/prompts/base/syntax.md +19 -0
  73. codealmanac/prompts/models.py +9 -0
  74. codealmanac/prompts/operations/garden.md +26 -0
  75. codealmanac/prompts/operations/ingest.md +18 -0
  76. codealmanac/prompts/renderer.py +24 -0
  77. codealmanac/prompts/requests.py +22 -0
  78. codealmanac/server/__init__.py +1 -0
  79. codealmanac/server/app.py +202 -0
  80. codealmanac/server/assets/__init__.py +1 -0
  81. codealmanac/server/assets/app.css +865 -0
  82. codealmanac/server/assets/app.js +3 -0
  83. codealmanac/server/assets/index.html +80 -0
  84. codealmanac/server/assets/viewer/api.js +30 -0
  85. codealmanac/server/assets/viewer/components.js +197 -0
  86. codealmanac/server/assets/viewer/main.js +126 -0
  87. codealmanac/server/assets/viewer/renderers.js +122 -0
  88. codealmanac/server/assets/viewer/routes.js +36 -0
  89. codealmanac/services/__init__.py +1 -0
  90. codealmanac/services/automation/__init__.py +3 -0
  91. codealmanac/services/automation/models.py +83 -0
  92. codealmanac/services/automation/ports.py +14 -0
  93. codealmanac/services/automation/requests.py +40 -0
  94. codealmanac/services/automation/service.py +294 -0
  95. codealmanac/services/config/__init__.py +17 -0
  96. codealmanac/services/config/models.py +61 -0
  97. codealmanac/services/config/requests.py +21 -0
  98. codealmanac/services/config/service.py +55 -0
  99. codealmanac/services/config/store.py +26 -0
  100. codealmanac/services/diagnostics/__init__.py +1 -0
  101. codealmanac/services/diagnostics/models.py +22 -0
  102. codealmanac/services/diagnostics/requests.py +8 -0
  103. codealmanac/services/diagnostics/service.py +283 -0
  104. codealmanac/services/harnesses/__init__.py +1 -0
  105. codealmanac/services/harnesses/models.py +104 -0
  106. codealmanac/services/harnesses/ports.py +18 -0
  107. codealmanac/services/harnesses/requests.py +19 -0
  108. codealmanac/services/harnesses/service.py +38 -0
  109. codealmanac/services/health/__init__.py +1 -0
  110. codealmanac/services/health/requests.py +8 -0
  111. codealmanac/services/health/service.py +20 -0
  112. codealmanac/services/index/__init__.py +1 -0
  113. codealmanac/services/index/models.py +135 -0
  114. codealmanac/services/index/requests.py +26 -0
  115. codealmanac/services/index/service.py +86 -0
  116. codealmanac/services/index/store.py +411 -0
  117. codealmanac/services/index/views.py +524 -0
  118. codealmanac/services/pages/__init__.py +1 -0
  119. codealmanac/services/pages/requests.py +17 -0
  120. codealmanac/services/pages/service.py +26 -0
  121. codealmanac/services/runs/__init__.py +1 -0
  122. codealmanac/services/runs/models.py +91 -0
  123. codealmanac/services/runs/requests.py +76 -0
  124. codealmanac/services/runs/service.py +86 -0
  125. codealmanac/services/runs/store.py +256 -0
  126. codealmanac/services/search/__init__.py +1 -0
  127. codealmanac/services/search/requests.py +23 -0
  128. codealmanac/services/search/service.py +31 -0
  129. codealmanac/services/sources/__init__.py +1 -0
  130. codealmanac/services/sources/models.py +126 -0
  131. codealmanac/services/sources/ports.py +30 -0
  132. codealmanac/services/sources/requests.py +76 -0
  133. codealmanac/services/sources/service.py +351 -0
  134. codealmanac/services/tagging/__init__.py +1 -0
  135. codealmanac/services/tagging/models.py +9 -0
  136. codealmanac/services/tagging/requests.py +35 -0
  137. codealmanac/services/tagging/service.py +43 -0
  138. codealmanac/services/topics/__init__.py +1 -0
  139. codealmanac/services/topics/models.py +36 -0
  140. codealmanac/services/topics/requests.py +115 -0
  141. codealmanac/services/topics/service.py +297 -0
  142. codealmanac/services/updates/__init__.py +4 -0
  143. codealmanac/services/updates/models.py +83 -0
  144. codealmanac/services/updates/ports.py +17 -0
  145. codealmanac/services/updates/requests.py +10 -0
  146. codealmanac/services/updates/service.py +113 -0
  147. codealmanac/services/viewer/__init__.py +1 -0
  148. codealmanac/services/viewer/models.py +80 -0
  149. codealmanac/services/viewer/renderer.py +89 -0
  150. codealmanac/services/viewer/requests.py +86 -0
  151. codealmanac/services/viewer/service.py +211 -0
  152. codealmanac/services/wiki/__init__.py +1 -0
  153. codealmanac/services/wiki/documents.py +83 -0
  154. codealmanac/services/wiki/frontmatter.py +94 -0
  155. codealmanac/services/wiki/frontmatter_rewrite.py +142 -0
  156. codealmanac/services/wiki/models.py +69 -0
  157. codealmanac/services/wiki/paths.py +42 -0
  158. codealmanac/services/wiki/service.py +57 -0
  159. codealmanac/services/wiki/templates.py +73 -0
  160. codealmanac/services/wiki/topics.py +266 -0
  161. codealmanac/services/wiki/wikilinks.py +58 -0
  162. codealmanac/services/workspaces/__init__.py +1 -0
  163. codealmanac/services/workspaces/models.py +124 -0
  164. codealmanac/services/workspaces/ports.py +9 -0
  165. codealmanac/services/workspaces/requests.py +82 -0
  166. codealmanac/services/workspaces/roots.py +74 -0
  167. codealmanac/services/workspaces/service.py +303 -0
  168. codealmanac/services/workspaces/store.py +127 -0
  169. codealmanac/workflows/__init__.py +1 -0
  170. codealmanac/workflows/build/__init__.py +1 -0
  171. codealmanac/workflows/build/models.py +8 -0
  172. codealmanac/workflows/build/service.py +45 -0
  173. codealmanac/workflows/garden/__init__.py +3 -0
  174. codealmanac/workflows/garden/models.py +30 -0
  175. codealmanac/workflows/garden/requests.py +22 -0
  176. codealmanac/workflows/garden/service.py +239 -0
  177. codealmanac/workflows/ingest/__init__.py +1 -0
  178. codealmanac/workflows/ingest/models.py +26 -0
  179. codealmanac/workflows/ingest/requests.py +39 -0
  180. codealmanac/workflows/ingest/service.py +302 -0
  181. codealmanac/workflows/lifecycle.py +197 -0
  182. codealmanac/workflows/sync/__init__.py +3 -0
  183. codealmanac/workflows/sync/models.py +157 -0
  184. codealmanac/workflows/sync/requests.py +63 -0
  185. codealmanac/workflows/sync/service.py +651 -0
  186. codealmanac/workflows/sync/store.py +51 -0
  187. codealmanac-0.1.0.dev0.dist-info/METADATA +248 -0
  188. codealmanac-0.1.0.dev0.dist-info/RECORD +192 -0
  189. codealmanac-0.1.0.dev0.dist-info/WHEEL +5 -0
  190. codealmanac-0.1.0.dev0.dist-info/entry_points.txt +2 -0
  191. codealmanac-0.1.0.dev0.dist-info/licenses/LICENSE.md +201 -0
  192. 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