codetrail-cli 1.4.1__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 (171) hide show
  1. codetrail/__init__.py +3 -0
  2. codetrail/__main__.py +5 -0
  3. codetrail/agents/__init__.py +4 -0
  4. codetrail/agents/convert.py +92 -0
  5. codetrail/agents/export.py +284 -0
  6. codetrail/agents/instructions.py +42 -0
  7. codetrail/agents/protocol.py +111 -0
  8. codetrail/agents/tools.py +185 -0
  9. codetrail/assistant/__init__.py +161 -0
  10. codetrail/assistant/claude_code.py +330 -0
  11. codetrail/assistant/codex.py +260 -0
  12. codetrail/assistant/environment.py +66 -0
  13. codetrail/assistant/estimate.py +225 -0
  14. codetrail/assistant/fake.py +74 -0
  15. codetrail/assistant/guard.py +80 -0
  16. codetrail/assistant/guard_hook.py +58 -0
  17. codetrail/assistant/local.py +245 -0
  18. codetrail/assistant/local_tools.py +177 -0
  19. codetrail/assistant/prompts.py +404 -0
  20. codetrail/assistant/routing.py +86 -0
  21. codetrail/assistant/runner.py +120 -0
  22. codetrail/assistant/status.py +186 -0
  23. codetrail/assistant/usage.py +93 -0
  24. codetrail/bridge.py +292 -0
  25. codetrail/cli.py +407 -0
  26. codetrail/config.py +485 -0
  27. codetrail/database/__init__.py +60 -0
  28. codetrail/database/migrations/0001_facts.sql +34 -0
  29. codetrail/database/migrations/0002_settings.sql +5 -0
  30. codetrail/database/migrations/0003_learning.sql +34 -0
  31. codetrail/database/migrations/0004_assistant_usage.sql +24 -0
  32. codetrail/database/migrations/0005_page_failures.sql +7 -0
  33. codetrail/database/migrations/0006_metric_values.sql +9 -0
  34. codetrail/errors.py +5 -0
  35. codetrail/extract/__init__.py +249 -0
  36. codetrail/extract/adr.py +98 -0
  37. codetrail/extract/github_actions.py +140 -0
  38. codetrail/extract/openapi.py +101 -0
  39. codetrail/extract/python.py +221 -0
  40. codetrail/extract/swift.py +204 -0
  41. codetrail/extract/terraform.py +166 -0
  42. codetrail/extract/typescript.py +464 -0
  43. codetrail/facts/__init__.py +112 -0
  44. codetrail/facts/store.py +209 -0
  45. codetrail/generate/__init__.py +1 -0
  46. codetrail/generate/failures.py +31 -0
  47. codetrail/generate/outline.py +159 -0
  48. codetrail/generate/revise.py +72 -0
  49. codetrail/generate/run.py +530 -0
  50. codetrail/generate/scope.py +135 -0
  51. codetrail/generate/validate.py +218 -0
  52. codetrail/guide/__init__.py +147 -0
  53. codetrail/learn/__init__.py +143 -0
  54. codetrail/learn/routes.py +139 -0
  55. codetrail/locales/ar/LC_MESSAGES/codetrail.mo +0 -0
  56. codetrail/locales/ar/LC_MESSAGES/codetrail.po +2236 -0
  57. codetrail/locales/codetrail.pot +1942 -0
  58. codetrail/lock.py +114 -0
  59. codetrail/metrics/__init__.py +6 -0
  60. codetrail/metrics/activity.py +96 -0
  61. codetrail/metrics/churn.py +156 -0
  62. codetrail/metrics/commits.py +45 -0
  63. codetrail/metrics/coverage.py +114 -0
  64. codetrail/metrics/decisions.py +77 -0
  65. codetrail/metrics/history.py +47 -0
  66. codetrail/metrics/inventory.py +63 -0
  67. codetrail/metrics/rationale.py +74 -0
  68. codetrail/metrics/releases.py +26 -0
  69. codetrail/metrics/report.py +111 -0
  70. codetrail/metrics/repository.py +128 -0
  71. codetrail/metrics/size.py +95 -0
  72. codetrail/py.typed +0 -0
  73. codetrail/remove.py +50 -0
  74. codetrail/repo/__init__.py +4 -0
  75. codetrail/repo/git.py +56 -0
  76. codetrail/repo/gitleaks.toml +6 -0
  77. codetrail/repo/history.py +262 -0
  78. codetrail/repo/mirror.py +137 -0
  79. codetrail/repo/refresh.py +41 -0
  80. codetrail/repo/rules.py +83 -0
  81. codetrail/repo/secrets.py +145 -0
  82. codetrail/repo/signal.py +65 -0
  83. codetrail/repo/source.py +166 -0
  84. codetrail/search.py +178 -0
  85. codetrail/server.py +45 -0
  86. codetrail/system.py +484 -0
  87. codetrail/update.py +276 -0
  88. codetrail/web/__init__.py +1 -0
  89. codetrail/web/app.py +897 -0
  90. codetrail/web/diagrams.py +414 -0
  91. codetrail/web/documentation.py +146 -0
  92. codetrail/web/i18n.py +44 -0
  93. codetrail/web/navigation.py +127 -0
  94. codetrail/web/render.py +126 -0
  95. codetrail/web/repository_view.py +230 -0
  96. codetrail/web/security.py +171 -0
  97. codetrail/web/static/brand/favicon.svg +1 -0
  98. codetrail/web/static/brand/mark.svg +1 -0
  99. codetrail/web/static/css/base.css +192 -0
  100. codetrail/web/static/css/components.css +2254 -0
  101. codetrail/web/static/css/content.css +310 -0
  102. codetrail/web/static/css/layout.css +410 -0
  103. codetrail/web/static/css/print.css +42 -0
  104. codetrail/web/static/css/tokens.css +228 -0
  105. codetrail/web/static/js/api.js +49 -0
  106. codetrail/web/static/js/ask.js +182 -0
  107. codetrail/web/static/js/d2-worker.js +34 -0
  108. codetrail/web/static/js/d2.js +28 -0
  109. codetrail/web/static/js/diagram.js +52 -0
  110. codetrail/web/static/js/documentation.js +24 -0
  111. codetrail/web/static/js/learning.js +47 -0
  112. codetrail/web/static/js/main.js +38 -0
  113. codetrail/web/static/js/outline.js +44 -0
  114. codetrail/web/static/js/palette.js +211 -0
  115. codetrail/web/static/js/repository.js +528 -0
  116. codetrail/web/static/js/resize.js +126 -0
  117. codetrail/web/static/js/shortcuts.js +87 -0
  118. codetrail/web/static/js/sidebar.js +194 -0
  119. codetrail/web/static/js/theme-init.js +27 -0
  120. codetrail/web/static/js/theme.js +42 -0
  121. codetrail/web/static/js/update.js +107 -0
  122. codetrail/web/static/vendor/d2/LICENSE +375 -0
  123. codetrail/web/static/vendor/d2/LICENSE-elk +263 -0
  124. codetrail/web/static/vendor/d2/LICENSE-wasm_exec +27 -0
  125. codetrail/web/static/vendor/d2/VERSION +12 -0
  126. codetrail/web/static/vendor/d2/d2.wasm +0 -0
  127. codetrail/web/static/vendor/d2/elk.js +105914 -0
  128. codetrail/web/static/vendor/d2/wasm_exec.js +656 -0
  129. codetrail/web/static/vendor/inter/InterVariable-Italic.woff2 +0 -0
  130. codetrail/web/static/vendor/inter/InterVariable.woff2 +0 -0
  131. codetrail/web/static/vendor/inter/LICENSE.txt +92 -0
  132. codetrail/web/static/vendor/inter/VERSION +6 -0
  133. codetrail/web/static/vendor/noto-sans-arabic/NotoSansArabic-wght.woff2 +0 -0
  134. codetrail/web/static/vendor/noto-sans-arabic/OFL.txt +93 -0
  135. codetrail/web/static/vendor/noto-sans-arabic/VERSION +12 -0
  136. codetrail/web/target_view.py +63 -0
  137. codetrail/web/templates/answers.html +25 -0
  138. codetrail/web/templates/area.html +46 -0
  139. codetrail/web/templates/base.html +37 -0
  140. codetrail/web/templates/decisions.html +30 -0
  141. codetrail/web/templates/diagram.html +10 -0
  142. codetrail/web/templates/digests.html +24 -0
  143. codetrail/web/templates/documentation.html +247 -0
  144. codetrail/web/templates/error.html +9 -0
  145. codetrail/web/templates/estimate.html +48 -0
  146. codetrail/web/templates/fact.html +29 -0
  147. codetrail/web/templates/home.html +196 -0
  148. codetrail/web/templates/layout/ask_panel.html +27 -0
  149. codetrail/web/templates/layout/header.html +32 -0
  150. codetrail/web/templates/layout/icons.html +27 -0
  151. codetrail/web/templates/layout/outline.html +4 -0
  152. codetrail/web/templates/layout/palette.html +27 -0
  153. codetrail/web/templates/layout/shortcuts.html +27 -0
  154. codetrail/web/templates/layout/sidebar.html +79 -0
  155. codetrail/web/templates/layout/update_dialog.html +18 -0
  156. codetrail/web/templates/macros.html +13 -0
  157. codetrail/web/templates/page.html +93 -0
  158. codetrail/web/templates/progress.html +46 -0
  159. codetrail/web/templates/repository.html +369 -0
  160. codetrail/web/templates/search.html +31 -0
  161. codetrail/web/templates/source.html +15 -0
  162. codetrail/web/templates/system.html +43 -0
  163. codetrail/web/templates/system_arrows.html +17 -0
  164. codetrail/web/templates/system_legend.html +9 -0
  165. codetrail/web/templates/update_log.html +30 -0
  166. codetrail/web/trend.py +22 -0
  167. codetrail_cli-1.4.1.dist-info/METADATA +372 -0
  168. codetrail_cli-1.4.1.dist-info/RECORD +171 -0
  169. codetrail_cli-1.4.1.dist-info/WHEEL +4 -0
  170. codetrail_cli-1.4.1.dist-info/entry_points.txt +3 -0
  171. codetrail_cli-1.4.1.dist-info/licenses/LICENSE +202 -0
codetrail/cli.py ADDED
@@ -0,0 +1,407 @@
1
+ """Codetrail's command line."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import signal
7
+ import sys
8
+ import unicodedata
9
+ from collections import Counter
10
+ from datetime import UTC, date, datetime
11
+ from pathlib import Path
12
+ from types import FrameType
13
+
14
+ from codetrail import __version__
15
+ from codetrail.agents.export import refresh_export
16
+ from codetrail.agents.instructions import server_instructions, setup_text
17
+ from codetrail.agents.protocol import serve_stdio
18
+ from codetrail.agents.tools import AgentTools
19
+ from codetrail.assistant.estimate import UpdateEstimate, describe, tokens_text
20
+ from codetrail.assistant.status import provider_status
21
+ from codetrail.config import PROVIDERS, Paths, load_global, load_target, validate_target_name, write_target
22
+ from codetrail.database import connect
23
+ from codetrail.errors import CodetrailError
24
+ from codetrail.facts import FactDiff
25
+ from codetrail.facts.store import FactStore
26
+ from codetrail.lock import target_in_use
27
+ from codetrail.metrics.history import values_before
28
+ from codetrail.metrics.report import Metric, build_report
29
+ from codetrail.metrics.repository import RepositoryReport, build_repository_report
30
+ from codetrail.remove import remove_target
31
+ from codetrail.repo.mirror import check_branch
32
+ from codetrail.repo.refresh import refresh_source
33
+ from codetrail.repo.rules import Reason
34
+ from codetrail.server import serve
35
+ from codetrail.update import run_update
36
+ from codetrail.web.documentation import COUNTS, age, build_tiles, percent_of
37
+
38
+ stopping = False # set once a hangup or terminate signal has started Codetrail's exit
39
+
40
+
41
+ def build_parser() -> argparse.ArgumentParser:
42
+ parser = argparse.ArgumentParser(prog="codetrail", description="Turn a git repository into a local learning guide.")
43
+ parser.add_argument("--version", action="version", version=f"codetrail {__version__}")
44
+ commands = parser.add_subparsers(dest="command")
45
+
46
+ target = commands.add_parser("target", help="manage the repositories Codetrail teaches")
47
+ target_commands = target.add_subparsers(dest="target_command", required=True)
48
+ add = target_commands.add_parser("add", help="register a repository; nothing is written into it")
49
+ add.add_argument("name", help="a short name: lower-case letters, digits and hyphens")
50
+ add.add_argument("path", type=Path, help="the repository's local checkout")
51
+ add.add_argument("--branch", default="develop", help="the branch to teach (default: develop)")
52
+ remove = target_commands.add_parser(
53
+ "remove", help="delete everything Codetrail keeps for a target; the repository isn't touched"
54
+ )
55
+ remove.add_argument("name")
56
+ remove.add_argument("--yes", action="store_true", help="delete without asking")
57
+
58
+ files = commands.add_parser("files", help="refresh a target's sources and list exactly what Codetrail can see")
59
+ files.add_argument("name")
60
+
61
+ update = commands.add_parser("update", help="refresh a target's sources and facts, and write the guide")
62
+ update.add_argument("name")
63
+ update.add_argument("--facts-only", action="store_true", help="refresh the facts without calling an assistant")
64
+ update.add_argument("--yes", action="store_true", help="go ahead after showing the estimate, without asking")
65
+ update.add_argument(
66
+ "--retry-failed", action="store_true", help="write pages that failed last time, even if nothing in them changed"
67
+ )
68
+
69
+ metrics = commands.add_parser("metrics", help="show how well a target's repository explains itself")
70
+ metrics.add_argument("name")
71
+
72
+ stats = commands.add_parser("stats", help="show a target repository's history, releases, code size and facts")
73
+ stats.add_argument("name")
74
+
75
+ commands.add_parser("providers", help="show each assistant provider: installed, signed in, and how")
76
+
77
+ agents = commands.add_parser("agents", help="rebuild the context for coding agents and print how to connect them")
78
+ agents.add_argument("name")
79
+ mcp = commands.add_parser("mcp", help="the tool server coding agents start over standard input and output")
80
+ mcp.add_argument("name")
81
+ serve_command = commands.add_parser("serve", help="serve the guide's page on 127.0.0.1")
82
+ serve_command.add_argument("name")
83
+ serve_command.add_argument("--no-browser", action="store_true", help="print the sign-in link without opening it")
84
+ return parser
85
+
86
+
87
+ def main(argv: list[str] | None = None) -> int:
88
+ for signal_number in (signal.SIGHUP, signal.SIGTERM):
89
+ if signal.getsignal(signal_number) == signal.SIG_DFL: # one ignored at start (nohup) stays ignored
90
+ signal.signal(signal_number, stop_on_signal)
91
+ parser = build_parser()
92
+ arguments = parser.parse_args(argv)
93
+ if arguments.command is None:
94
+ parser.print_help(file=sys.stderr)
95
+ return 2
96
+ paths = Paths.from_environment()
97
+ try:
98
+ if arguments.command == "target" and arguments.target_command == "remove":
99
+ return remove_target_command(paths, arguments.name, yes=arguments.yes)
100
+ if arguments.command == "target":
101
+ return add_target(paths, arguments.name, arguments.path, arguments.branch)
102
+ if arguments.command == "providers":
103
+ return show_providers(paths)
104
+ if arguments.command == "metrics":
105
+ return show_metrics(paths, arguments.name)
106
+ if arguments.command == "stats":
107
+ return show_stats(paths, arguments.name)
108
+ if arguments.command == "update":
109
+ return update_target(paths, arguments.name, facts_only=arguments.facts_only, yes=arguments.yes,
110
+ retry_failed=arguments.retry_failed) # fmt: skip
111
+ if arguments.command == "mcp":
112
+ return serve_agent_tools(paths, arguments.name)
113
+ if arguments.command == "agents":
114
+ return connect_agents(paths, arguments.name)
115
+ if arguments.command == "serve":
116
+ serve(paths, arguments.name, open_browser=not arguments.no_browser)
117
+ return 0
118
+ return list_files(paths, arguments.name)
119
+ except CodetrailError as error:
120
+ print(f"codetrail: {error}", file=sys.stderr)
121
+ return 1
122
+
123
+
124
+ def connect_agents(paths: Paths, name: str) -> int:
125
+ folder = refresh_export(paths, name)
126
+ print(setup_text(name, sys.executable, folder), end="")
127
+ return 0
128
+
129
+
130
+ def serve_agent_tools(paths: Paths, name: str) -> int:
131
+ """Runs the tool server until the agent closes standard input (design section 23.5)."""
132
+ target = load_target(paths, name)
133
+ tools = AgentTools(paths, name, load_global(paths).agents, str(target.repository))
134
+ serve_stdio(tools, server_instructions(name, str(target.repository)), sys.stdin.buffer, sys.stdout.buffer)
135
+ return 0
136
+
137
+
138
+ def stop_on_signal(signal_number: int, _frame: FrameType | None) -> None:
139
+ """Turns a hangup or terminate signal into an exception, so the cleanup that stops child programs runs.
140
+
141
+ Child programs run in their own session (design section 15.5), so these signals don't reach them; by default
142
+ Python would die without unwinding and leave them running. It fires once: a second signal (closing a terminal can
143
+ send two) mustn't interrupt the cleanup. The signals stay caught rather than ignored, since an ignored signal stays
144
+ ignored in any program the cleanup starts.
145
+ """
146
+ global stopping
147
+ if stopping:
148
+ return
149
+ stopping = True
150
+ raise SystemExit(128 + signal_number)
151
+
152
+
153
+ def add_target(paths: Paths, name: str, path: Path, branch: str) -> int:
154
+ validate_target_name(name)
155
+ path = path.expanduser().resolve()
156
+ if not (path / ".git").exists():
157
+ raise CodetrailError(f"{path} isn't a git repository (it has no .git).")
158
+ check_branch(path, branch)
159
+ file = write_target(paths, name, path, branch)
160
+ print(f"Added target {name!r}: {path} ({branch}). Settings: {file}")
161
+ print(f"Exclusions you add go in {paths.ignore_file(name)} (gitignore syntax).")
162
+ return 0
163
+
164
+
165
+ def remove_target_command(paths: Paths, name: str, yes: bool = False) -> int:
166
+ no_terminal = False
167
+
168
+ def confirm(locations: list[Path]) -> bool:
169
+ nonlocal no_terminal
170
+ print(f"Removing target {name!r} deletes:")
171
+ for location in locations:
172
+ print(f" {printable(str(location))}")
173
+ print("The repository itself isn't touched.")
174
+ if yes:
175
+ return True
176
+ if not sys.stdin.isatty():
177
+ no_terminal = True
178
+ print("There's no terminal to ask in, so nothing was removed. Run again with --yes to go ahead.")
179
+ return False
180
+ return input(f"Remove target {name!r} and delete these? [y/N] ").strip().lower() in ("y", "yes")
181
+
182
+ if not remove_target(paths, name, confirm):
183
+ if not no_terminal:
184
+ print("Nothing was removed.")
185
+ return 2 if no_terminal else 0
186
+ print(f"Removed target {name!r}.")
187
+ return 0
188
+
189
+
190
+ def printable(path: str) -> str:
191
+ """A path from a target, quoted with escapes if it holds characters a terminal would act on."""
192
+ if any(unicodedata.category(character) in ("Cc", "Cf", "Cs", "Zl", "Zp") for character in path):
193
+ return repr(path)
194
+ return path
195
+
196
+
197
+ def list_files(paths: Paths, name: str) -> int:
198
+ manifest = refresh_source(paths, name)
199
+ for path in manifest.files:
200
+ print(printable(path))
201
+ print("Excluded:")
202
+ for item in manifest.excluded:
203
+ reason = f"{item.reason} ({item.rule})" if item.rule else str(item.reason)
204
+ print(f"{reason}\t{printable(item.path)}")
205
+ counts = Counter(item.reason for item in manifest.excluded)
206
+ breakdown = ", ".join(f"{counts[reason]} {reason}" for reason in Reason if counts[reason])
207
+ print(
208
+ f"{len(manifest.files)} visible, {len(manifest.excluded)} excluded ({breakdown or 'none'}), "
209
+ f"commit {manifest.commit[:12]}"
210
+ )
211
+ return 0
212
+
213
+
214
+ def show_providers(paths: Paths) -> int:
215
+ settings = load_global(paths)
216
+ for provider in PROVIDERS:
217
+ status = provider_status(provider, settings)
218
+ state = "ready" if status.ready else "not ready"
219
+ how = " · ".join(part for part in (status.method, status.program) if part)
220
+ print(f"{provider:<12} {state:<10} {how}")
221
+ if status.models:
222
+ print(f"{'':<12} models: {', '.join(status.models)}")
223
+ if status.fix:
224
+ print(f"{'':<12} {status.fix}")
225
+ if status.warning:
226
+ print(f"{'':<12} Note: {status.warning}")
227
+ return 0
228
+
229
+
230
+ def update_target(
231
+ paths: Paths, name: str, facts_only: bool = False, yes: bool = False, retry_failed: bool = False
232
+ ) -> int:
233
+ no_terminal = False
234
+
235
+ def confirm(estimate: UpdateEstimate) -> bool:
236
+ """Shows the estimate and asks before any paid work (design section 15.4)."""
237
+ nonlocal no_terminal
238
+ if not estimate.lines:
239
+ print("Nothing for the assistant to do in this update.")
240
+ return True
241
+ for line in describe(estimate):
242
+ print(printable(line) if line.startswith(" ") or line.startswith("Skipped") else line)
243
+ if yes:
244
+ return True
245
+ if not sys.stdin.isatty():
246
+ no_terminal = True
247
+ print("There's no terminal to ask in, so nothing was spent. Run again with --yes to go ahead.")
248
+ return False
249
+ return input("Continue? [y/N] ").strip().lower() in ("y", "yes")
250
+
251
+ result = run_update(paths, name, facts_only=facts_only, confirm=None if facts_only else confirm,
252
+ retry_failed=retry_failed) # fmt: skip
253
+ extraction, diff = result.extraction, result.diff
254
+ print(f"Updated {name} at commit {result.manifest.commit[:12]} (snapshot {result.snapshot.id}).")
255
+ print(f"Facts: {len(extraction.entities)} entities, {len(extraction.relations)} relations.")
256
+ if extraction.system:
257
+ print(
258
+ "System connections: " + ", ".join(f"{rule} {count}" for rule, count in sorted(extraction.system.items()))
259
+ )
260
+ if diff.is_empty:
261
+ print("No changes since the last update.")
262
+ else:
263
+ print("Changes (+ added, ~ changed, - removed):")
264
+ for kind, counts in sorted(_change_counts(diff).items()):
265
+ print(f" {kind}: +{counts[0]} ~{counts[1]} -{counts[2]}")
266
+ for extractor, count in sorted(extraction.unresolved.items()):
267
+ print(f"Unresolved references ({extractor}): {count}")
268
+ for warning in extraction.warnings:
269
+ print(f"Warning: {printable(warning)}")
270
+ if result.declined:
271
+ print("The facts were refreshed; the guide wasn't updated.")
272
+ return 2 if no_terminal else 0
273
+ generation = result.generation
274
+ if generation is not None:
275
+ print(
276
+ f"Guide: {len(generation.written)} pages written, {len(generation.failed)} failed, "
277
+ f"{len(generation.left_for_later)} left for the next update; "
278
+ f"used ~{tokens_text(generation.tokens)} tokens, ${generation.cost_usd:.2f} at API prices."
279
+ )
280
+ for page, reason in generation.failed:
281
+ print(f" Not rewritten: {page} ({printable(reason)})")
282
+ for page in generation.skipped:
283
+ print(f" Skipped: {page} (it failed last time, and nothing in it changed since)")
284
+ if generation.skipped:
285
+ print(f" To write them anyway: codetrail update {name} --retry-failed")
286
+ for problem in generation.outline_problems:
287
+ print(f" Outline: {printable(problem)}")
288
+ if generation.digest:
289
+ print(f" Digest: {generation.digest}")
290
+ return 0
291
+
292
+
293
+ METRIC_LABELS = {
294
+ Metric.DOCUMENTED_SHARE: "Documented rationale",
295
+ Metric.COMMIT_WHY_SHARE: "Commits that explain why",
296
+ Metric.ADR_ATTENTION: "ADRs needing attention",
297
+ Metric.MENTIONED_SHARE: "Mentioned in a document",
298
+ Metric.EXPLAINED_SHARE: "Explained by the guide",
299
+ }
300
+
301
+
302
+ def show_metrics(paths: Paths, name: str) -> int:
303
+ """Prints each documentation metric with its change since the last update (design 18.4); writes nothing."""
304
+ load_target(paths, name)
305
+ database = paths.target_data(name) / "codetrail.db"
306
+ with target_in_use(paths, name):
307
+ if not database.exists():
308
+ print(f"No facts yet. Run: codetrail update {name} --facts-only")
309
+ return 1
310
+ connection = connect(database)
311
+ try:
312
+ store = FactStore(connection)
313
+ snapshot = store.latest_snapshot()
314
+ if snapshot is None:
315
+ print(f"No facts yet. Run: codetrail update {name} --facts-only")
316
+ return 1
317
+ report = build_report(paths, name, load_global(paths), store, date.today())
318
+ tiles = build_tiles(report.values(), values_before(connection, snapshot.id), {})
319
+ finally:
320
+ connection.close()
321
+ for metric, label in METRIC_LABELS.items():
322
+ tile = tiles[metric]
323
+ if tile.value is None:
324
+ print(f"{label}: not available, the commit history couldn't be read")
325
+ elif metric in (Metric.DOCUMENTED_SHARE, Metric.EXPLAINED_SHARE) and not report.rationale.pages:
326
+ print(f"{label}: no pages yet")
327
+ else:
328
+ if metric in COUNTS:
329
+ shown = str(tile.value.numerator)
330
+ elif tile.percent is None:
331
+ shown = "nothing to measure yet"
332
+ else:
333
+ shown = f"{tile.percent}% ({tile.value.numerator} of {tile.value.denominator})"
334
+ unit = "" if metric in COUNTS else " points"
335
+ change = "no earlier update" if tile.change is None else f"{tile.change:+d}{unit} since the last update"
336
+ print(f"{label}: {shown}, {change}")
337
+ return 0
338
+
339
+
340
+ def show_stats(paths: Paths, name: str) -> int:
341
+ """Prints the repository statistics (design 19.5); reads the history but never writes it."""
342
+ load_target(paths, name)
343
+ database = paths.target_data(name) / "codetrail.db"
344
+ settings = load_global(paths)
345
+ with target_in_use(paths, name):
346
+ if not database.exists():
347
+ print(f"No facts yet. Run: codetrail update {name} --facts-only")
348
+ return 1
349
+ connection = connect(database)
350
+ try:
351
+ store = FactStore(connection)
352
+ if store.latest_snapshot() is None:
353
+ print(f"No facts yet. Run: codetrail update {name} --facts-only")
354
+ return 1
355
+ report = build_repository_report(paths, name, settings, store, date.today())
356
+ finally:
357
+ connection.close()
358
+ print_stats(report, date.today(), settings.metrics.max_listed)
359
+ return 0
360
+
361
+
362
+ def _plural(count: int, word: str) -> str:
363
+ return f"{count} {word}" if count == 1 else f"{count} {word}s"
364
+
365
+
366
+ def print_stats(report: RepositoryReport, today: date, listed: int) -> None:
367
+ activity, releases, size = report.activity, report.releases, report.size
368
+ if activity is None or activity.first is None or activity.latest is None:
369
+ print("History: not available, the commit history couldn't be read")
370
+ else:
371
+ years, months = age(activity.first, today)
372
+ if years or months:
373
+ ago = f"{_plural(years, 'year')}, {_plural(months, 'month')}"
374
+ else:
375
+ ago = _plural(max((today - activity.first).days, 0), "day")
376
+ print(f"First commit: {activity.first.isoformat()} ({ago} ago)")
377
+ print(f"Latest commit: {activity.latest.isoformat()}")
378
+ print(f"Commits: {activity.commits} ({_plural(activity.merges, 'merge')})")
379
+ if releases is None:
380
+ print("Releases: not available, the tags couldn't be read")
381
+ elif not releases.tags:
382
+ print("Releases: none")
383
+ else:
384
+ latest = releases.tags[0]
385
+ day = datetime.fromtimestamp(latest.date, UTC).date().isoformat()
386
+ print(f"Releases: {len(releases.tags)}, the latest {printable(latest.name)} on {day}, "
387
+ f"{_plural(releases.since_latest or 0, 'commit')} since") # fmt: skip
388
+ print(f"Code: {size.code_lines} lines in {_plural(len(size.languages), 'language')}")
389
+ for language in size.languages[:listed]:
390
+ print(f" {language.language}: {_plural(language.files, 'file')}, {_plural(language.lines, 'line')}")
391
+ print(f"Documents: {_plural(size.documents.files, 'file')}, {_plural(size.documents.lines, 'line')}")
392
+ share = percent_of(report.values()[Metric.TEST_SHARE])
393
+ shown = "nothing to measure" if share is None else f"{share}%"
394
+ print(f"Tests: {shown} ({size.test_lines} of {size.code_lines} lines)")
395
+ kinds = ", ".join(f"{kind.count} {kind.kind}" for kind in report.inventory.kinds)
396
+ print(f"Facts: {kinds or 'none'}")
397
+
398
+
399
+ def _change_counts(diff: FactDiff) -> dict[str, list[int]]:
400
+ counts: dict[str, list[int]] = {}
401
+ for position, ids in enumerate((diff.added_entities, diff.changed_entities, diff.removed_entities)):
402
+ for entity_id in ids:
403
+ counts.setdefault(entity_id.split(":", 1)[0], [0, 0, 0])[position] += 1
404
+ for position, keys in enumerate((diff.added_relations, diff.changed_relations, diff.removed_relations)):
405
+ for key in keys:
406
+ counts.setdefault(key[1], [0, 0, 0])[position] += 1
407
+ return counts