@allansantos-dev/smart-tool 0.0.0-stage → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/LICENSE +202 -0
  3. package/NOTICE +32 -0
  4. package/README.md +116 -3
  5. package/atomic_io.py +94 -0
  6. package/autostart.py +199 -0
  7. package/bin/smart-tool.mjs +82 -0
  8. package/browser_service_client.py +232 -0
  9. package/capabilities.py +63 -0
  10. package/client_hooks.py +192 -0
  11. package/code_graph.py +403 -0
  12. package/code_graph_java.mjs +49 -0
  13. package/code_graph_js.cjs +135 -0
  14. package/config.py +241 -0
  15. package/daemon_launcher.py +165 -0
  16. package/directory_watch.py +131 -0
  17. package/document_text.py +69 -0
  18. package/duplicates.py +313 -0
  19. package/embedding_cache.py +99 -0
  20. package/endpoint_sync.py +179 -0
  21. package/folder_picker.py +73 -0
  22. package/freshness.py +278 -0
  23. package/gateway.py +220 -0
  24. package/hook_decision.py +96 -0
  25. package/hooks/pretool_router.py +45 -0
  26. package/index_inventory.py +75 -0
  27. package/index_profile.py +167 -0
  28. package/index_scope.py +458 -0
  29. package/index_views.py +121 -0
  30. package/indexer.py +878 -0
  31. package/install.py +251 -0
  32. package/install_runtime.py +249 -0
  33. package/integration_metrics.py +82 -0
  34. package/local_embedder.py +172 -0
  35. package/model_adapters/__init__.py +0 -0
  36. package/model_adapters/openai_compatible.py +57 -0
  37. package/model_catalog.py +74 -0
  38. package/model_client.py +38 -0
  39. package/model_defaults.py +70 -0
  40. package/package.json +35 -6
  41. package/paths.py +6 -0
  42. package/project_identity.py +42 -0
  43. package/project_insights_ui.py +38 -0
  44. package/project_maps_ui.py +96 -0
  45. package/project_monitor.py +204 -0
  46. package/project_scheduler.py +61 -0
  47. package/project_storage.py +165 -0
  48. package/project_store.py +220 -0
  49. package/projects_ui.py +146 -0
  50. package/requirements.txt +8 -0
  51. package/research_cache.py +171 -0
  52. package/research_metrics.py +110 -0
  53. package/router.py +582 -0
  54. package/secret_store.py +121 -0
  55. package/setup_ui.py +986 -0
  56. package/site_memory.py +53 -0
  57. package/smart_tool_daemon.py +3604 -0
  58. package/tray.py +245 -0
  59. package/update_check.py +22 -0
  60. package/usage_meter.py +124 -0
  61. package/version.py +18 -0
  62. package/view_compare.py +68 -0
  63. package/web_adapters/browser/browser_service.py +302 -0
  64. package/web_adapters/browser/crawl4ai_search.py +236 -0
  65. package/web_adapters/browser/requirements.txt +2 -0
  66. package/web_adapters/node/adapter-common.mjs +40 -0
  67. package/web_adapters/node/ddg-search.mjs +89 -0
  68. package/web_adapters/node/open-websearch-adapter.mjs +77 -0
  69. package/web_adapters/node/package-lock.json +2720 -0
  70. package/web_adapters/node/package.json +12 -0
  71. package/web_adapters/node/wikipedia-search.mjs +44 -0
  72. package/web_document_graph.py +71 -0
  73. package/web_fetch.py +242 -0
  74. package/web_search_adapters.py +193 -0
  75. package/web_search_ddgs.py +42 -0
  76. package/web_search_external.py +288 -0
  77. package/web_search_health.py +88 -0
package/index_scope.py ADDED
@@ -0,0 +1,458 @@
1
+ #!/usr/bin/env python3
2
+ """Escopo de indexação decidido por LLM, por repositório (stdlib only).
3
+
4
+ Cada repositório tem convenções diferentes (ex.: `.gitignore` tira `docs/` do controle de
5
+ versão, mas `docs/` é ótima fonte pra busca) — em vez de um classificador mecânico de
6
+ exclusão, um LLM analisa a estrutura do repo uma vez e decide o que entra/sai do índice.
7
+ A decisão é cacheada por path do projeto; re-scan automático só ocorre se surgirem
8
+ diretórios de topo novos desde a última decisão, ou via comando manual (`force=True`).
9
+ """
10
+ import fnmatch
11
+ import hashlib
12
+ import json
13
+ import os
14
+ import paths
15
+ import time
16
+
17
+ import atomic_io
18
+ import project_identity
19
+ import index_views
20
+
21
+ import model_client
22
+
23
+ SCOPE_DIR = paths.DATA_DIR
24
+
25
+ # Names safe to exclude everywhere without inspection: dependency/tooling caches whose
26
+ # purpose does not vary by project convention. Ambiguous names (dist, build, out, bin...)
27
+ # are deliberately NOT here — those must be inspected, since their meaning is project-specific.
28
+ _ALWAYS_EXCLUDE = {
29
+ ".git", "node_modules", "__pycache__", ".venv", "venv", "env",
30
+ ".tox", ".mypy_cache", ".pytest_cache", "*.egg-info",
31
+ }
32
+
33
+ # Mesmo critério do `_ALWAYS_EXCLUDE`, para arquivo: o significado não varia por
34
+ # projeto. Lockfile é grafo de dependência resolvido por ferramenta — nunca é fonte de
35
+ # resposta numa busca semântica, e é grande o bastante pra dominar o índice.
36
+ _ALWAYS_EXCLUDE_FILES = {
37
+ "package-lock.json", "yarn.lock", "pnpm-lock.yaml", "npm-shrinkwrap.json",
38
+ "poetry.lock", "pdm.lock", "uv.lock", "Pipfile.lock", "Cargo.lock",
39
+ "composer.lock", "go.sum", "Gemfile.lock", "packages.lock.json",
40
+ }
41
+
42
+ _ROOT_DOC_NAMES = ("AGENTS.md", "CLAUDE.md", "README.md", "README")
43
+
44
+
45
+ def always_excluded(name):
46
+ """Directory name in _ALWAYS_EXCLUDE, entries may be globs (e.g. *.egg-info)."""
47
+ folded = name.casefold()
48
+ return any(fnmatch.fnmatch(folded, pattern.casefold()) for pattern in _ALWAYS_EXCLUDE)
49
+
50
+ # Versão do contrato de decisão de escopo. Mudar o que é perguntado ao decisor exige
51
+ # incrementar: sem isso, um repositório com escopo já cacheado nunca é reavaliado, porque
52
+ # `needs_rescan` só olha diretório de topo novo.
53
+ SCOPE_VERSION = 4
54
+
55
+
56
+ def _within_root(root, candidate):
57
+ """`candidate` está dentro de `root` depois de resolver link/junction. Comparar só
58
+ `abspath` (sem resolver) deixa passar junction do Windows, que aponta pra fora sem
59
+ que `os.path.islink()` acuse."""
60
+ return project_identity.within_root(root, candidate)
61
+
62
+ _INSPECT_TOOL = {
63
+ "type": "function",
64
+ "function": {
65
+ "name": "inspect_directory",
66
+ "description": (
67
+ "Inspect a directory's real contents (file count, extension histogram, sample "
68
+ "filenames) before deciding whether to include or exclude it from the search "
69
+ "index. Call this for every directory whose purpose is not already settled by "
70
+ "the project documentation — never decide from the directory name alone."
71
+ ),
72
+ "parameters": {
73
+ "type": "object",
74
+ "properties": {
75
+ "path": {"type": "string", "description": "Path relative to the project root"},
76
+ },
77
+ "required": ["path"],
78
+ },
79
+ },
80
+ }
81
+
82
+ _SCOPE_TOOL = {
83
+ "type": "function",
84
+ "function": {
85
+ "name": "submit_scope",
86
+ "description": "Submit the include/exclude scope decision for the search index.",
87
+ "parameters": {
88
+ "type": "object",
89
+ "properties": {
90
+ "include": {"type": "array", "items": {"type": "string"}},
91
+ "exclude": {"type": "array", "items": {"type": "string"}},
92
+ },
93
+ "required": ["include", "exclude"],
94
+ },
95
+ },
96
+ }
97
+
98
+
99
+ def _project_hash(root):
100
+ return index_views.storage_key(root)
101
+
102
+
103
+ def scope_path(root):
104
+ return os.path.join(SCOPE_DIR, _project_hash(root) + ".scope.json")
105
+
106
+
107
+ def _top_level_dirs(root):
108
+ try:
109
+ names = os.listdir(root)
110
+ except OSError:
111
+ return []
112
+ return sorted(
113
+ name for name in names
114
+ if not name.startswith(".")
115
+ and not always_excluded(name)
116
+ and os.path.isdir(os.path.join(root, name))
117
+ and _within_root(root, os.path.join(root, name))
118
+ )
119
+
120
+
121
+ def valid_scope(scope):
122
+ return (isinstance(scope, dict) and all(
123
+ isinstance(scope.get(key, []), list) and all(isinstance(item, str) for item in scope.get(key, []))
124
+ for key in ("include", "exclude", "user_exclude", "top_level_dirs", "structure")))
125
+
126
+
127
+ def load_scope(root):
128
+ paths = [scope_path(root)] + [os.path.join(SCOPE_DIR, key + ".scope.json")
129
+ for key in project_identity.legacy_ids(root)]
130
+ for path in dict.fromkeys(paths):
131
+ try:
132
+ with open(path, "r", encoding="utf-8") as f:
133
+ data = json.load(f)
134
+ if valid_scope(data):
135
+ return data
136
+ except (OSError, ValueError, UnicodeError):
137
+ continue
138
+ return None
139
+
140
+
141
+ def save_scope(root, scope):
142
+ if not valid_scope(scope):
143
+ raise ValueError("Scope must contain lists of paths in include/exclude.")
144
+ index_views.assert_current(index_views.describe(root))
145
+ os.makedirs(SCOPE_DIR, exist_ok=True)
146
+ atomic_io.write_secret_text(scope_path(root), json.dumps(
147
+ {**scope, "root": project_identity.canonical_root(root)}, indent=2, ensure_ascii=False))
148
+
149
+
150
+ def _structure(root, max_dirs=1500):
151
+ found = []
152
+ for current, dirs, _files in os.walk(root):
153
+ dirs[:] = sorted(d for d in dirs if not d.startswith(".") and not always_excluded(d)
154
+ and _within_root(root, os.path.join(current, d)))
155
+ for name in dirs:
156
+ found.append(os.path.relpath(os.path.join(current, name), root).replace(os.sep, "/"))
157
+ if len(found) >= max_dirs:
158
+ return found
159
+ return found
160
+
161
+
162
+ def _docs_digest(root):
163
+ return hashlib.sha256(_read_root_docs(root).encode("utf-8")).hexdigest()
164
+
165
+
166
+ def needs_rescan(root, cached_scope):
167
+ if not valid_scope(cached_scope):
168
+ return True
169
+ if cached_scope.get("manual"):
170
+ return False
171
+ if cached_scope.get("scope_version") != SCOPE_VERSION:
172
+ return True
173
+ if cached_scope.get("docs_digest") != _docs_digest(root):
174
+ return True
175
+ known = set(cached_scope.get("top_level_dirs", []))
176
+ current = set(_top_level_dirs(root))
177
+ return (not current.issubset(known) or
178
+ not set(_structure(root)).issubset(set(cached_scope.get("structure", []))))
179
+
180
+
181
+ def _read_root_docs(root, max_chars=4000):
182
+ parts = []
183
+ budget = max_chars
184
+ for name in _ROOT_DOC_NAMES:
185
+ if budget <= 0:
186
+ break
187
+ full = os.path.join(root, name)
188
+ if not os.path.isfile(full) or not _within_root(root, full):
189
+ continue
190
+ try:
191
+ with open(full, "r", encoding="utf-8", errors="ignore") as f:
192
+ text = f.read(budget)
193
+ except OSError:
194
+ continue
195
+ parts.append(f"--- {name} ---\n{text}")
196
+ budget -= len(text)
197
+ return "\n\n".join(parts)
198
+
199
+
200
+ def _inspect_directory(root, rel_path, max_files=4000, sample_size=15, top_ext=12):
201
+ root_full = os.path.normpath(os.path.abspath(root))
202
+ base_full = os.path.normpath(os.path.abspath(os.path.join(root, rel_path)))
203
+ if not _within_root(root_full, base_full):
204
+ return {"error": "path outside the project root"}
205
+ if not os.path.isdir(base_full):
206
+ return {"error": f"directory not found: {rel_path}"}
207
+
208
+ ext_counts = {}
209
+ samples = []
210
+ total = 0
211
+ total_size = 0
212
+ max_depth = 0
213
+ for dirpath, _dirnames, filenames in os.walk(base_full):
214
+ _dirnames[:] = [name for name in _dirnames if not name.startswith(".")
215
+ and not always_excluded(name)
216
+ and _within_root(root, os.path.join(dirpath, name))]
217
+ depth = os.path.relpath(dirpath, base_full).count(os.sep) + (0 if dirpath == base_full else 1)
218
+ max_depth = max(max_depth, depth)
219
+ for name in filenames:
220
+ total += 1
221
+ if total > max_files:
222
+ break
223
+ ext = os.path.splitext(name)[1].lower() or "(no extension)"
224
+ ext_counts[ext] = ext_counts.get(ext, 0) + 1
225
+ full = os.path.join(dirpath, name)
226
+ try:
227
+ total_size += os.path.getsize(full)
228
+ except OSError:
229
+ pass
230
+ if len(samples) < sample_size:
231
+ samples.append(os.path.relpath(full, base_full).replace(os.sep, "/"))
232
+ if total > max_files:
233
+ break
234
+
235
+ top_extensions = dict(sorted(ext_counts.items(), key=lambda kv: -kv[1])[:top_ext])
236
+ return {
237
+ "path": rel_path,
238
+ "file_count": total,
239
+ "truncated": total > max_files,
240
+ "total_size_bytes": total_size,
241
+ "max_depth": max_depth,
242
+ "extensions": top_extensions,
243
+ "sample_files": samples,
244
+ }
245
+
246
+
247
+ def _build_scope_prompt(root, ambiguous_dirs, root_files, doc_context):
248
+ return (
249
+ "You are deciding what to include in a semantic code-search index for the "
250
+ f"repository at {root}.\n\n"
251
+ "These directories are already excluded automatically (dependency/tooling caches "
252
+ f"whose meaning does not vary by project): {', '.join(sorted(_ALWAYS_EXCLUDE))}. "
253
+ "Do not decide on them.\n\n"
254
+ f"Directories that still need a decision: {', '.join(ambiguous_dirs) or '(none)'}\n\n"
255
+ "Loose files at the repository ROOT are indexed automatically, because in a flat "
256
+ "repository they are the whole codebase and no directory in `include` can stand "
257
+ f"for them. These are the ones found: {', '.join(root_files) or '(none)'}. "
258
+ "If any of them is generated output, a data dump, a duplicate of another file, or "
259
+ "otherwise useless for code search, list it in `exclude` by name — `exclude` "
260
+ "accepts file paths and glob patterns (e.g. 'notes/*.log'), not only directory "
261
+ "names. Files holding credentials are dropped mechanically elsewhere; you do not "
262
+ "need to decide on those.\n\n"
263
+ "Project documentation found at the root (may state project-specific conventions):\n"
264
+ f"{doc_context or '(none found)'}\n\n"
265
+ "IMPORTANT: names like 'dist', 'build', 'out', 'bin', 'target' do NOT reliably mean "
266
+ "'generated build output' — some projects use them to hold real hand-written source. "
267
+ "Never decide from the directory name alone. For every directory listed above whose "
268
+ "purpose is not already settled by the documentation, call inspect_directory(path) "
269
+ "and look at the real extensions/sample filenames before deciding. If the project "
270
+ "documentation above explicitly states what a directory is for, that statement "
271
+ "overrides any inference you would otherwise draw from file extensions or the "
272
+ "directory's name — do not exclude a directory the documentation identifies as "
273
+ "source, even if it also contains a few generated/log files. Only call submit_scope "
274
+ "once every non-obvious directory is grounded in inspection data or in the "
275
+ "documentation.\n\n"
276
+ "Include sources useful for search even if they are gitignored (e.g. docs/, README, "
277
+ "ADRs). Java, Angular, HTML/CSS, plain text and Markdown are supported sources. "
278
+ "DOCX documents are supported through bounded local text extraction; do not exclude "
279
+ "useful Word documentation merely because its container is binary. "
280
+ "Exclude directories that inspection shows are generated artifacts or "
281
+ "dependencies (compiled binaries, minified bundles, hashed build chunks, vendored "
282
+ "packages)."
283
+ )
284
+
285
+
286
+ def decide_scope(root, router_model, force=False, deadline=None, cancel_check=None):
287
+ cached = load_scope(root)
288
+ if not force and cached is not None and not needs_rescan(root, cached):
289
+ return cached
290
+
291
+ top_level_dirs = _top_level_dirs(root)
292
+ ambiguous_dirs = [d for d in top_level_dirs if not always_excluded(d)]
293
+ root_files = _root_level_files(root, _exclude_matcher(_ALWAYS_EXCLUDE_FILES))
294
+ doc_context = _read_root_docs(root)
295
+
296
+ token = model_client.get_token()
297
+ messages = [{"role": "user", "content": _build_scope_prompt(root, ambiguous_dirs, root_files, doc_context)}]
298
+ tools = [_INSPECT_TOOL, _SCOPE_TOOL]
299
+ decision = None
300
+
301
+ for _ in range(min(len(ambiguous_dirs) + 5, 30)):
302
+ if cancel_check:
303
+ cancel_check()
304
+ remaining = deadline - time.monotonic() if deadline is not None else 60
305
+ if remaining <= 0:
306
+ raise TimeoutError("Scope analysis deadline exceeded.")
307
+ response = model_client.fetch(
308
+ "/v1/chat/completions",
309
+ token,
310
+ method="POST",
311
+ timeout=min(60, remaining),
312
+ body={
313
+ "model": router_model,
314
+ "messages": messages,
315
+ "tools": tools,
316
+ "tool_choice": "required",
317
+ "temperature": 0,
318
+ },
319
+ )
320
+ message = response["choices"][0]["message"]
321
+ tool_calls = message.get("tool_calls") or []
322
+ if not tool_calls:
323
+ break
324
+ messages.append({"role": "assistant", "content": message.get("content"), "tool_calls": tool_calls})
325
+
326
+ for tool_call in tool_calls:
327
+ name = tool_call["function"]["name"]
328
+ try:
329
+ args = json.loads(tool_call["function"]["arguments"] or "{}")
330
+ except json.JSONDecodeError:
331
+ args = {}
332
+ if not isinstance(args, dict):
333
+ args = {}
334
+ if name == "submit_scope":
335
+ if isinstance(args, dict) and "include" in args and "exclude" in args and valid_scope(args):
336
+ decision = args
337
+ result_text = "ok"
338
+ else:
339
+ result_text = "include/exclude must be arrays of strings"
340
+ elif name == "inspect_directory":
341
+ result_text = json.dumps(_inspect_directory(root, args.get("path", "")), ensure_ascii=False)
342
+ else:
343
+ result_text = json.dumps({"error": f"tool desconhecida: {name}"})
344
+ messages.append({"role": "tool", "tool_call_id": tool_call["id"], "content": result_text})
345
+
346
+ if decision is not None:
347
+ break
348
+
349
+ if decision is None:
350
+ raise RuntimeError("The model did not define a valid scope. Check the model or set the scope manually.")
351
+ scope = {
352
+ "include": (decision or {}).get("include") or [],
353
+ "exclude": (decision or {}).get("exclude") or [],
354
+ "top_level_dirs": top_level_dirs,
355
+ "scope_version": SCOPE_VERSION,
356
+ "structure": _structure(root),
357
+ "docs_digest": _docs_digest(root),
358
+ "user_exclude": (cached or {}).get("user_exclude", []),
359
+ "decided_at": time.time(),
360
+ }
361
+ save_scope(root, scope)
362
+ return scope
363
+
364
+
365
+ def _root_level_files(root, matcher):
366
+ """Arquivos soltos na raiz do projeto, sem descer em nenhum diretório.
367
+
368
+ Num repositório de fontes planos (Python/PowerShell sem `src/`) eles são o código
369
+ todo, e `include` lista diretórios — então nenhuma entrada de `include` os
370
+ representa. `_build_scope_prompt` mostra esta mesma lista ao decisor, que pode
371
+ nomeá-los em `exclude`."""
372
+ try:
373
+ names = sorted(os.listdir(root))
374
+ except OSError as exc:
375
+ raise OSError("Could not list the root; the existing index was kept.") from exc
376
+ out = []
377
+ for name in names:
378
+ if matcher(name):
379
+ continue
380
+ full = os.path.join(root, name)
381
+ # `isfile` segue link: sem a checagem de contenção, um link na raiz apontando
382
+ # pra fora do projeto entraria com um `rel_path` que parece interno.
383
+ if os.path.isfile(full) and _within_root(root, full):
384
+ out.append(name)
385
+ return out
386
+
387
+
388
+ def _exclude_matcher(exclude):
389
+ """Predicado "está excluído?" tolerante ao que um LLM de fato emite: glob
390
+ (`logs/*.log`), separador do Windows, caixa diferente da do disco e prefixo
391
+ de diretório. Uma exclusão que não casa é pior que nenhuma — o decisor acredita ter
392
+ tirado o arquivo do índice."""
393
+ patterns = set()
394
+ for raw in exclude:
395
+ norm = str(raw).replace("\\", "/").strip("/").lower()
396
+ if norm.startswith("./"):
397
+ norm = norm[2:]
398
+ if norm:
399
+ patterns.add(norm)
400
+
401
+ def matches(rel_path):
402
+ target = rel_path.replace("\\", "/").lower()
403
+ name = target.rsplit("/", 1)[-1]
404
+ for pattern in patterns:
405
+ if target == pattern or name == pattern:
406
+ return True
407
+ if target.startswith(pattern + "/"):
408
+ return True
409
+ if ("*" in pattern or "?" in pattern or "[" in pattern) and (
410
+ fnmatch.fnmatch(target, pattern) or fnmatch.fnmatch(name, pattern)
411
+ ):
412
+ return True
413
+ return False
414
+
415
+ return matches
416
+
417
+
418
+ def resolve_included_files(root, scope, cancel_check=None):
419
+ if not valid_scope(scope):
420
+ raise ValueError("Invalid scope; request a new project analysis.")
421
+ include = scope.get("include") or [""]
422
+ matcher = _exclude_matcher(set(scope.get("exclude") or []) | set(scope.get("user_exclude") or []) |
423
+ _ALWAYS_EXCLUDE | _ALWAYS_EXCLUDE_FILES)
424
+ files = []
425
+ def walk_error(error):
426
+ raise OSError("Could not list a folder in the scope; try again when it is accessible.") from error
427
+ if not any(base in ("", ".") for base in include):
428
+ files.extend(_root_level_files(root, matcher))
429
+ for base in include:
430
+ base_full = os.path.join(root, base) if base not in ("", ".") else root
431
+ # `include` vem de tool call de um LLM cujo prompt embute documentação do
432
+ # repositório analisado: um valor absoluto ou com `..` faria a varredura sair do
433
+ # projeto e mandar conteúdo de fora pro endpoint de embeddings.
434
+ if not _within_root(root, base_full):
435
+ continue
436
+ if os.path.isfile(base_full):
437
+ rel = os.path.relpath(base_full, root).replace(os.sep, "/")
438
+ if not matcher(rel):
439
+ files.append(rel)
440
+ continue
441
+ for dirpath, dirnames, filenames in os.walk(base_full, onerror=walk_error):
442
+ if cancel_check:
443
+ cancel_check()
444
+ rel_dir = os.path.relpath(dirpath, root).replace(os.sep, "/")
445
+ rel_dir = "" if rel_dir == "." else rel_dir
446
+ dirnames[:] = [
447
+ d for d in dirnames
448
+ if not d.startswith(".")
449
+ and not matcher(f"{rel_dir}/{d}" if rel_dir else d)
450
+ # `os.walk` não desce em symlink de diretório, mas `os.path.islink()` é
451
+ # False para junction do Windows (`mklink /J`, sem admin) — e desce nela.
452
+ and _within_root(root, os.path.join(dirpath, d))
453
+ ]
454
+ for name in filenames:
455
+ rel_path = f"{rel_dir}/{name}" if rel_dir else name
456
+ if not matcher(rel_path) and _within_root(root, os.path.join(root, rel_path)):
457
+ files.append(rel_path)
458
+ return sorted(set(files))
package/index_views.py ADDED
@@ -0,0 +1,121 @@
1
+ """Visão Git do índice. Branches isolam manifests; a raiz continua sendo o projeto."""
2
+ import contextlib
3
+ import contextvars
4
+ import hashlib
5
+ import os
6
+ import shutil
7
+ import subprocess
8
+ import time
9
+
10
+ import project_identity
11
+
12
+ _PINNED = contextvars.ContextVar('smart_index_view', default=None)
13
+ _CACHE = {}
14
+
15
+
16
+ class ViewChanged(RuntimeError):
17
+ """Checkout/commit durante uma leitura: não publicar resultado de outra visão."""
18
+
19
+
20
+ def _git_marker(root):
21
+ current = project_identity.display_root(root)
22
+ while True:
23
+ if os.path.lexists(os.path.join(current, '.git')):
24
+ return True
25
+ parent = os.path.dirname(current)
26
+ if parent == current:
27
+ return False
28
+ current = parent
29
+
30
+
31
+ def _run(root, *args, optional=False):
32
+ result = subprocess.run(['git', '--no-optional-locks', '-C', root, *args],
33
+ stdin=subprocess.DEVNULL, capture_output=True, timeout=5,
34
+ creationflags=getattr(subprocess, 'CREATE_NO_WINDOW', 0))
35
+ if result.returncode:
36
+ if optional and result.returncode in (1, 128):
37
+ return ''
38
+ raise RuntimeError('Could not identify the Git view. Check access to the repository.')
39
+ return result.stdout.decode('utf-8', 'replace').strip()
40
+
41
+
42
+ def _stamp(view):
43
+ paths = [os.path.join(view['git_dir'], name) for name in ('HEAD', 'index', 'logs/HEAD')]
44
+ paths += [os.path.join(view['common_dir'], 'packed-refs')]
45
+ if view.get('ref'):
46
+ paths.append(os.path.join(view['common_dir'], view['ref']))
47
+ result = []
48
+ for path in paths:
49
+ try:
50
+ stat = os.stat(path)
51
+ result.append((stat.st_mtime_ns, stat.st_size))
52
+ except OSError:
53
+ result.append(None)
54
+ return tuple(result)
55
+
56
+
57
+ def describe(root, fresh=False):
58
+ root = project_identity.display_root(root)
59
+ key = project_identity.canonical_root(root)
60
+ pinned = _PINNED.get()
61
+ if pinned and pinned['root'] == key and not fresh:
62
+ return pinned
63
+ old = _CACHE.get(key)
64
+ if not fresh and old and time.monotonic()-old[0] < 1:
65
+ return old[1]
66
+ view = {'root': key, 'git': False, 'view_id': 'workspace', 'label': 'Folder without Git',
67
+ 'branch': None, 'commit': None}
68
+ if _git_marker(root):
69
+ if not shutil.which('git'):
70
+ raise RuntimeError('This folder contains Git, but the git executable is not available.')
71
+ paths = _run(root, 'rev-parse', '--path-format=absolute', '--show-toplevel', '--absolute-git-dir', '--git-common-dir').splitlines()
72
+ if len(paths) != 3:
73
+ raise RuntimeError('Git did not report the repository directories.')
74
+ view.update(git_dir=paths[1], common_dir=paths[2])
75
+ before = _stamp(view)
76
+ ref = _run(root, 'symbolic-ref', '-q', 'HEAD', optional=True)
77
+ view['ref'] = ref
78
+ ref_before = _stamp(view)
79
+ commit = _run(root, 'rev-parse', '--verify', 'HEAD', optional=True)
80
+ if not ref and not commit:
81
+ raise RuntimeError('Could not identify the Git HEAD.')
82
+ identity = ref or 'detached:' + commit
83
+ view.update(git=True, ref=ref, branch=ref.removeprefix('refs/heads/') if ref else None,
84
+ commit=commit or None, repo_root=paths[0], git_dir=paths[1], common_dir=paths[2],
85
+ view_id=hashlib.sha256(identity.encode()).hexdigest()[:16],
86
+ label=ref.removeprefix('refs/heads/') if ref else 'Detached ' + commit[:10])
87
+ view['stamp'] = _stamp(view)
88
+ if view['stamp'] != ref_before or view['stamp'][:4] != before[:4]:
89
+ raise ViewChanged('Git changed while its view was being identified. Try again after the checkout.')
90
+ _CACHE[key] = (time.monotonic(), view)
91
+ return view
92
+
93
+
94
+ def storage_key(root):
95
+ view = describe(root)
96
+ key = project_identity.project_id(root)
97
+ return key + '.v-' + view['view_id'] if view['git'] else key
98
+
99
+
100
+ def assert_current(view):
101
+ if view.get('git') and _stamp(view) != view.get('stamp'):
102
+ _CACHE.pop(view['root'], None)
103
+ raise ViewChanged('The Git view changed during the operation. The result was discarded; search again on the current branch.')
104
+
105
+
106
+ @contextlib.contextmanager
107
+ def pin(root):
108
+ current = _PINNED.get()
109
+ if current and current['root'] == project_identity.canonical_root(root):
110
+ yield current
111
+ return
112
+ view = describe(root, fresh=True)
113
+ token = _PINNED.set(view)
114
+ try:
115
+ yield view
116
+ finally:
117
+ _PINNED.reset(token)
118
+
119
+
120
+ def public(view):
121
+ return {key: view.get(key) for key in ('git', 'view_id', 'label', 'branch', 'commit')}