htmlgraph 0.20.1__py3-none-any.whl → 0.27.5__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 (304) hide show
  1. htmlgraph/.htmlgraph/.session-warning-state.json +6 -0
  2. htmlgraph/.htmlgraph/agents.json +72 -0
  3. htmlgraph/.htmlgraph/htmlgraph.db +0 -0
  4. htmlgraph/__init__.py +51 -1
  5. htmlgraph/__init__.pyi +123 -0
  6. htmlgraph/agent_detection.py +26 -10
  7. htmlgraph/agent_registry.py +2 -1
  8. htmlgraph/analytics/__init__.py +8 -1
  9. htmlgraph/analytics/cli.py +86 -20
  10. htmlgraph/analytics/cost_analyzer.py +391 -0
  11. htmlgraph/analytics/cost_monitor.py +664 -0
  12. htmlgraph/analytics/cost_reporter.py +675 -0
  13. htmlgraph/analytics/cross_session.py +617 -0
  14. htmlgraph/analytics/dependency.py +10 -6
  15. htmlgraph/analytics/pattern_learning.py +771 -0
  16. htmlgraph/analytics/session_graph.py +707 -0
  17. htmlgraph/analytics/strategic/__init__.py +80 -0
  18. htmlgraph/analytics/strategic/cost_optimizer.py +611 -0
  19. htmlgraph/analytics/strategic/pattern_detector.py +876 -0
  20. htmlgraph/analytics/strategic/preference_manager.py +709 -0
  21. htmlgraph/analytics/strategic/suggestion_engine.py +747 -0
  22. htmlgraph/analytics/work_type.py +67 -27
  23. htmlgraph/analytics_index.py +53 -20
  24. htmlgraph/api/__init__.py +3 -0
  25. htmlgraph/api/cost_alerts_websocket.py +416 -0
  26. htmlgraph/api/main.py +2498 -0
  27. htmlgraph/api/static/htmx.min.js +1 -0
  28. htmlgraph/api/static/style-redesign.css +1344 -0
  29. htmlgraph/api/static/style.css +1079 -0
  30. htmlgraph/api/templates/dashboard-redesign.html +1366 -0
  31. htmlgraph/api/templates/dashboard.html +794 -0
  32. htmlgraph/api/templates/partials/activity-feed-hierarchical.html +326 -0
  33. htmlgraph/api/templates/partials/activity-feed.html +1100 -0
  34. htmlgraph/api/templates/partials/agents-redesign.html +317 -0
  35. htmlgraph/api/templates/partials/agents.html +317 -0
  36. htmlgraph/api/templates/partials/event-traces.html +373 -0
  37. htmlgraph/api/templates/partials/features-kanban-redesign.html +509 -0
  38. htmlgraph/api/templates/partials/features.html +578 -0
  39. htmlgraph/api/templates/partials/metrics-redesign.html +346 -0
  40. htmlgraph/api/templates/partials/metrics.html +346 -0
  41. htmlgraph/api/templates/partials/orchestration-redesign.html +443 -0
  42. htmlgraph/api/templates/partials/orchestration.html +198 -0
  43. htmlgraph/api/templates/partials/spawners.html +375 -0
  44. htmlgraph/api/templates/partials/work-items.html +613 -0
  45. htmlgraph/api/websocket.py +538 -0
  46. htmlgraph/archive/__init__.py +24 -0
  47. htmlgraph/archive/bloom.py +234 -0
  48. htmlgraph/archive/fts.py +297 -0
  49. htmlgraph/archive/manager.py +583 -0
  50. htmlgraph/archive/search.py +244 -0
  51. htmlgraph/atomic_ops.py +560 -0
  52. htmlgraph/attribute_index.py +2 -1
  53. htmlgraph/bounded_paths.py +539 -0
  54. htmlgraph/builders/base.py +57 -2
  55. htmlgraph/builders/bug.py +19 -3
  56. htmlgraph/builders/chore.py +19 -3
  57. htmlgraph/builders/epic.py +19 -3
  58. htmlgraph/builders/feature.py +27 -3
  59. htmlgraph/builders/insight.py +2 -1
  60. htmlgraph/builders/metric.py +2 -1
  61. htmlgraph/builders/pattern.py +2 -1
  62. htmlgraph/builders/phase.py +19 -3
  63. htmlgraph/builders/spike.py +29 -3
  64. htmlgraph/builders/track.py +42 -1
  65. htmlgraph/cigs/__init__.py +81 -0
  66. htmlgraph/cigs/autonomy.py +385 -0
  67. htmlgraph/cigs/cost.py +475 -0
  68. htmlgraph/cigs/messages_basic.py +472 -0
  69. htmlgraph/cigs/messaging.py +365 -0
  70. htmlgraph/cigs/models.py +771 -0
  71. htmlgraph/cigs/pattern_storage.py +427 -0
  72. htmlgraph/cigs/patterns.py +503 -0
  73. htmlgraph/cigs/posttool_analyzer.py +234 -0
  74. htmlgraph/cigs/reporter.py +818 -0
  75. htmlgraph/cigs/tracker.py +317 -0
  76. htmlgraph/cli/.htmlgraph/.session-warning-state.json +6 -0
  77. htmlgraph/cli/.htmlgraph/agents.json +72 -0
  78. htmlgraph/cli/.htmlgraph/htmlgraph.db +0 -0
  79. htmlgraph/cli/__init__.py +42 -0
  80. htmlgraph/cli/__main__.py +6 -0
  81. htmlgraph/cli/analytics.py +1424 -0
  82. htmlgraph/cli/base.py +685 -0
  83. htmlgraph/cli/constants.py +206 -0
  84. htmlgraph/cli/core.py +954 -0
  85. htmlgraph/cli/main.py +147 -0
  86. htmlgraph/cli/models.py +475 -0
  87. htmlgraph/cli/templates/__init__.py +1 -0
  88. htmlgraph/cli/templates/cost_dashboard.py +399 -0
  89. htmlgraph/cli/work/__init__.py +239 -0
  90. htmlgraph/cli/work/browse.py +115 -0
  91. htmlgraph/cli/work/features.py +568 -0
  92. htmlgraph/cli/work/orchestration.py +676 -0
  93. htmlgraph/cli/work/report.py +728 -0
  94. htmlgraph/cli/work/sessions.py +466 -0
  95. htmlgraph/cli/work/snapshot.py +559 -0
  96. htmlgraph/cli/work/tracks.py +486 -0
  97. htmlgraph/cli_commands/__init__.py +1 -0
  98. htmlgraph/cli_commands/feature.py +195 -0
  99. htmlgraph/cli_framework.py +115 -0
  100. htmlgraph/collections/__init__.py +2 -0
  101. htmlgraph/collections/base.py +197 -14
  102. htmlgraph/collections/bug.py +2 -1
  103. htmlgraph/collections/chore.py +2 -1
  104. htmlgraph/collections/epic.py +2 -1
  105. htmlgraph/collections/feature.py +2 -1
  106. htmlgraph/collections/insight.py +2 -1
  107. htmlgraph/collections/metric.py +2 -1
  108. htmlgraph/collections/pattern.py +2 -1
  109. htmlgraph/collections/phase.py +2 -1
  110. htmlgraph/collections/session.py +194 -0
  111. htmlgraph/collections/spike.py +13 -2
  112. htmlgraph/collections/task_delegation.py +241 -0
  113. htmlgraph/collections/todo.py +14 -1
  114. htmlgraph/collections/traces.py +487 -0
  115. htmlgraph/config/cost_models.json +56 -0
  116. htmlgraph/config.py +190 -0
  117. htmlgraph/context_analytics.py +2 -1
  118. htmlgraph/converter.py +116 -7
  119. htmlgraph/cost_analysis/__init__.py +5 -0
  120. htmlgraph/cost_analysis/analyzer.py +438 -0
  121. htmlgraph/dashboard.html +2246 -248
  122. htmlgraph/dashboard.html.backup +6592 -0
  123. htmlgraph/dashboard.html.bak +7181 -0
  124. htmlgraph/dashboard.html.bak2 +7231 -0
  125. htmlgraph/dashboard.html.bak3 +7232 -0
  126. htmlgraph/db/__init__.py +38 -0
  127. htmlgraph/db/queries.py +790 -0
  128. htmlgraph/db/schema.py +1788 -0
  129. htmlgraph/decorators.py +317 -0
  130. htmlgraph/dependency_models.py +2 -1
  131. htmlgraph/deploy.py +26 -27
  132. htmlgraph/docs/API_REFERENCE.md +841 -0
  133. htmlgraph/docs/HTTP_API.md +750 -0
  134. htmlgraph/docs/INTEGRATION_GUIDE.md +752 -0
  135. htmlgraph/docs/ORCHESTRATION_PATTERNS.md +717 -0
  136. htmlgraph/docs/README.md +532 -0
  137. htmlgraph/docs/__init__.py +77 -0
  138. htmlgraph/docs/docs_version.py +55 -0
  139. htmlgraph/docs/metadata.py +93 -0
  140. htmlgraph/docs/migrations.py +232 -0
  141. htmlgraph/docs/template_engine.py +143 -0
  142. htmlgraph/docs/templates/_sections/cli_reference.md.j2 +52 -0
  143. htmlgraph/docs/templates/_sections/core_concepts.md.j2 +29 -0
  144. htmlgraph/docs/templates/_sections/sdk_basics.md.j2 +69 -0
  145. htmlgraph/docs/templates/base_agents.md.j2 +78 -0
  146. htmlgraph/docs/templates/example_user_override.md.j2 +47 -0
  147. htmlgraph/docs/version_check.py +163 -0
  148. htmlgraph/edge_index.py +2 -1
  149. htmlgraph/error_handler.py +544 -0
  150. htmlgraph/event_log.py +86 -37
  151. htmlgraph/event_migration.py +2 -1
  152. htmlgraph/file_watcher.py +12 -8
  153. htmlgraph/find_api.py +2 -1
  154. htmlgraph/git_events.py +67 -9
  155. htmlgraph/hooks/.htmlgraph/.session-warning-state.json +6 -0
  156. htmlgraph/hooks/.htmlgraph/agents.json +72 -0
  157. htmlgraph/hooks/.htmlgraph/index.sqlite +0 -0
  158. htmlgraph/hooks/__init__.py +8 -0
  159. htmlgraph/hooks/bootstrap.py +169 -0
  160. htmlgraph/hooks/cigs_pretool_enforcer.py +354 -0
  161. htmlgraph/hooks/concurrent_sessions.py +208 -0
  162. htmlgraph/hooks/context.py +350 -0
  163. htmlgraph/hooks/drift_handler.py +525 -0
  164. htmlgraph/hooks/event_tracker.py +790 -99
  165. htmlgraph/hooks/git_commands.py +175 -0
  166. htmlgraph/hooks/installer.py +5 -1
  167. htmlgraph/hooks/orchestrator.py +327 -76
  168. htmlgraph/hooks/orchestrator_reflector.py +31 -4
  169. htmlgraph/hooks/post_tool_use_failure.py +32 -7
  170. htmlgraph/hooks/post_tool_use_handler.py +257 -0
  171. htmlgraph/hooks/posttooluse.py +92 -19
  172. htmlgraph/hooks/pretooluse.py +527 -7
  173. htmlgraph/hooks/prompt_analyzer.py +637 -0
  174. htmlgraph/hooks/session_handler.py +668 -0
  175. htmlgraph/hooks/session_summary.py +395 -0
  176. htmlgraph/hooks/state_manager.py +504 -0
  177. htmlgraph/hooks/subagent_detection.py +202 -0
  178. htmlgraph/hooks/subagent_stop.py +369 -0
  179. htmlgraph/hooks/task_enforcer.py +99 -4
  180. htmlgraph/hooks/validator.py +212 -91
  181. htmlgraph/ids.py +2 -1
  182. htmlgraph/learning.py +125 -100
  183. htmlgraph/mcp_server.py +2 -1
  184. htmlgraph/models.py +217 -18
  185. htmlgraph/operations/README.md +62 -0
  186. htmlgraph/operations/__init__.py +79 -0
  187. htmlgraph/operations/analytics.py +339 -0
  188. htmlgraph/operations/bootstrap.py +289 -0
  189. htmlgraph/operations/events.py +244 -0
  190. htmlgraph/operations/fastapi_server.py +231 -0
  191. htmlgraph/operations/hooks.py +350 -0
  192. htmlgraph/operations/initialization.py +597 -0
  193. htmlgraph/operations/initialization.py.backup +228 -0
  194. htmlgraph/operations/server.py +303 -0
  195. htmlgraph/orchestration/__init__.py +58 -0
  196. htmlgraph/orchestration/claude_launcher.py +179 -0
  197. htmlgraph/orchestration/command_builder.py +72 -0
  198. htmlgraph/orchestration/headless_spawner.py +281 -0
  199. htmlgraph/orchestration/live_events.py +377 -0
  200. htmlgraph/orchestration/model_selection.py +327 -0
  201. htmlgraph/orchestration/plugin_manager.py +140 -0
  202. htmlgraph/orchestration/prompts.py +137 -0
  203. htmlgraph/orchestration/spawner_event_tracker.py +383 -0
  204. htmlgraph/orchestration/spawners/__init__.py +16 -0
  205. htmlgraph/orchestration/spawners/base.py +194 -0
  206. htmlgraph/orchestration/spawners/claude.py +173 -0
  207. htmlgraph/orchestration/spawners/codex.py +435 -0
  208. htmlgraph/orchestration/spawners/copilot.py +294 -0
  209. htmlgraph/orchestration/spawners/gemini.py +471 -0
  210. htmlgraph/orchestration/subprocess_runner.py +36 -0
  211. htmlgraph/{orchestration.py → orchestration/task_coordination.py} +16 -8
  212. htmlgraph/orchestration.md +563 -0
  213. htmlgraph/orchestrator-system-prompt-optimized.txt +863 -0
  214. htmlgraph/orchestrator.py +2 -1
  215. htmlgraph/orchestrator_config.py +357 -0
  216. htmlgraph/orchestrator_mode.py +115 -4
  217. htmlgraph/parallel.py +2 -1
  218. htmlgraph/parser.py +86 -6
  219. htmlgraph/path_query.py +608 -0
  220. htmlgraph/pattern_matcher.py +636 -0
  221. htmlgraph/pydantic_models.py +476 -0
  222. htmlgraph/quality_gates.py +350 -0
  223. htmlgraph/query_builder.py +2 -1
  224. htmlgraph/query_composer.py +509 -0
  225. htmlgraph/reflection.py +443 -0
  226. htmlgraph/refs.py +344 -0
  227. htmlgraph/repo_hash.py +512 -0
  228. htmlgraph/repositories/__init__.py +292 -0
  229. htmlgraph/repositories/analytics_repository.py +455 -0
  230. htmlgraph/repositories/analytics_repository_standard.py +628 -0
  231. htmlgraph/repositories/feature_repository.py +581 -0
  232. htmlgraph/repositories/feature_repository_htmlfile.py +668 -0
  233. htmlgraph/repositories/feature_repository_memory.py +607 -0
  234. htmlgraph/repositories/feature_repository_sqlite.py +858 -0
  235. htmlgraph/repositories/filter_service.py +620 -0
  236. htmlgraph/repositories/filter_service_standard.py +445 -0
  237. htmlgraph/repositories/shared_cache.py +621 -0
  238. htmlgraph/repositories/shared_cache_memory.py +395 -0
  239. htmlgraph/repositories/track_repository.py +552 -0
  240. htmlgraph/repositories/track_repository_htmlfile.py +619 -0
  241. htmlgraph/repositories/track_repository_memory.py +508 -0
  242. htmlgraph/repositories/track_repository_sqlite.py +711 -0
  243. htmlgraph/sdk/__init__.py +398 -0
  244. htmlgraph/sdk/__init__.pyi +14 -0
  245. htmlgraph/sdk/analytics/__init__.py +19 -0
  246. htmlgraph/sdk/analytics/engine.py +155 -0
  247. htmlgraph/sdk/analytics/helpers.py +178 -0
  248. htmlgraph/sdk/analytics/registry.py +109 -0
  249. htmlgraph/sdk/base.py +484 -0
  250. htmlgraph/sdk/constants.py +216 -0
  251. htmlgraph/sdk/core.pyi +308 -0
  252. htmlgraph/sdk/discovery.py +120 -0
  253. htmlgraph/sdk/help/__init__.py +12 -0
  254. htmlgraph/sdk/help/mixin.py +699 -0
  255. htmlgraph/sdk/mixins/__init__.py +15 -0
  256. htmlgraph/sdk/mixins/attribution.py +113 -0
  257. htmlgraph/sdk/mixins/mixin.py +410 -0
  258. htmlgraph/sdk/operations/__init__.py +12 -0
  259. htmlgraph/sdk/operations/mixin.py +427 -0
  260. htmlgraph/sdk/orchestration/__init__.py +17 -0
  261. htmlgraph/sdk/orchestration/coordinator.py +203 -0
  262. htmlgraph/sdk/orchestration/spawner.py +204 -0
  263. htmlgraph/sdk/planning/__init__.py +19 -0
  264. htmlgraph/sdk/planning/bottlenecks.py +93 -0
  265. htmlgraph/sdk/planning/mixin.py +211 -0
  266. htmlgraph/sdk/planning/parallel.py +186 -0
  267. htmlgraph/sdk/planning/queue.py +210 -0
  268. htmlgraph/sdk/planning/recommendations.py +87 -0
  269. htmlgraph/sdk/planning/smart_planning.py +319 -0
  270. htmlgraph/sdk/session/__init__.py +19 -0
  271. htmlgraph/sdk/session/continuity.py +57 -0
  272. htmlgraph/sdk/session/handoff.py +110 -0
  273. htmlgraph/sdk/session/info.py +309 -0
  274. htmlgraph/sdk/session/manager.py +103 -0
  275. htmlgraph/sdk/strategic/__init__.py +26 -0
  276. htmlgraph/sdk/strategic/mixin.py +563 -0
  277. htmlgraph/server.py +295 -107
  278. htmlgraph/session_hooks.py +300 -0
  279. htmlgraph/session_manager.py +285 -3
  280. htmlgraph/session_registry.py +587 -0
  281. htmlgraph/session_state.py +436 -0
  282. htmlgraph/session_warning.py +2 -1
  283. htmlgraph/sessions/__init__.py +23 -0
  284. htmlgraph/sessions/handoff.py +756 -0
  285. htmlgraph/system_prompts.py +450 -0
  286. htmlgraph/templates/orchestration-view.html +350 -0
  287. htmlgraph/track_builder.py +33 -1
  288. htmlgraph/track_manager.py +38 -0
  289. htmlgraph/transcript.py +18 -5
  290. htmlgraph/validation.py +115 -0
  291. htmlgraph/watch.py +2 -1
  292. htmlgraph/work_type_utils.py +2 -1
  293. {htmlgraph-0.20.1.data → htmlgraph-0.27.5.data}/data/htmlgraph/dashboard.html +2246 -248
  294. {htmlgraph-0.20.1.dist-info → htmlgraph-0.27.5.dist-info}/METADATA +95 -64
  295. htmlgraph-0.27.5.dist-info/RECORD +337 -0
  296. {htmlgraph-0.20.1.dist-info → htmlgraph-0.27.5.dist-info}/entry_points.txt +1 -1
  297. htmlgraph/cli.py +0 -4839
  298. htmlgraph/sdk.py +0 -2359
  299. htmlgraph-0.20.1.dist-info/RECORD +0 -118
  300. {htmlgraph-0.20.1.data → htmlgraph-0.27.5.data}/data/htmlgraph/styles.css +0 -0
  301. {htmlgraph-0.20.1.data → htmlgraph-0.27.5.data}/data/htmlgraph/templates/AGENTS.md.template +0 -0
  302. {htmlgraph-0.20.1.data → htmlgraph-0.27.5.data}/data/htmlgraph/templates/CLAUDE.md.template +0 -0
  303. {htmlgraph-0.20.1.data → htmlgraph-0.27.5.data}/data/htmlgraph/templates/GEMINI.md.template +0 -0
  304. {htmlgraph-0.20.1.dist-info → htmlgraph-0.27.5.dist-info}/WHEEL +0 -0
htmlgraph/repo_hash.py ADDED
@@ -0,0 +1,512 @@
1
+ from __future__ import annotations
2
+
3
+ """
4
+ Repository Hashing and Git Awareness Module.
5
+
6
+ Provides stable repository identification and git state tracking for:
7
+ - Unique repo identification across machines/clones
8
+ - Stable hashes from: path + remote URL + inode
9
+ - Stability across branch changes
10
+ - Monorepo support (multiple projects = different hashes)
11
+ - Git state tracking: branch, commit, dirty flag
12
+
13
+ Architecture:
14
+ RepoHash(repo_path) → compute stable hash + git info
15
+
16
+ Hash inputs:
17
+ 1. Absolute repository path
18
+ 2. Git remote URL (if available)
19
+ 3. File system inode
20
+
21
+ Outputs:
22
+ - repo_hash: "repo-abc123def456" (stable, unique)
23
+ - git_info: {branch, commit, remote, dirty, last_commit_date}
24
+ - monorepo_project: "project-name" (if in monorepo)
25
+ """
26
+
27
+
28
+ import hashlib
29
+ import logging
30
+ import os
31
+ import subprocess
32
+ from datetime import datetime
33
+ from pathlib import Path
34
+ from typing import Any
35
+
36
+ logger = logging.getLogger(__name__)
37
+
38
+
39
+ class RepoHash:
40
+ """
41
+ Generate stable hashes for git repositories.
42
+
43
+ Provides unique repository identification and git state tracking.
44
+ Hash is stable across branch changes and independent of file modifications.
45
+ """
46
+
47
+ def __init__(self, repo_path: Path | None = None):
48
+ """
49
+ Initialize with git repository path.
50
+
51
+ Args:
52
+ repo_path: Path to the git repository. Defaults to current directory.
53
+
54
+ Raises:
55
+ OSError: If path does not exist.
56
+ """
57
+ if repo_path is None:
58
+ repo_path = Path.cwd()
59
+ else:
60
+ repo_path = Path(repo_path)
61
+
62
+ if not repo_path.exists():
63
+ raise OSError(f"Repository path does not exist: {repo_path}")
64
+
65
+ self.repo_path = repo_path.resolve()
66
+ self._git_info_cache: dict[str, Any] | None = None
67
+ self._repo_hash_cache: str | None = None
68
+
69
+ def compute_repo_hash(self) -> str:
70
+ """
71
+ Compute stable hash from path + remote + inode.
72
+
73
+ Hash inputs:
74
+ 1. Absolute repo path
75
+ 2. Git remote URL (if available)
76
+ 3. File system inode
77
+
78
+ Returns:
79
+ Hex string like 'repo-abc123def456'
80
+
81
+ The hash is deterministic: same repo always produces same hash.
82
+ Branch changes do not affect the hash.
83
+ """
84
+ if self._repo_hash_cache is not None:
85
+ return self._repo_hash_cache
86
+
87
+ # Get hash inputs
88
+ path_str = str(self.repo_path.absolute())
89
+ remote = get_git_remote(self.repo_path)
90
+ inode = get_inode(self.repo_path)
91
+
92
+ # Compute hash
93
+ hash_input = compute_hash_inputs(path_str, remote, inode)
94
+ hash_hex = hashlib.sha256(hash_input.encode()).hexdigest()[:12]
95
+
96
+ result = f"repo-{hash_hex}"
97
+ self._repo_hash_cache = result
98
+ return result
99
+
100
+ def get_git_info(self) -> dict[str, Any]:
101
+ """
102
+ Get current git state.
103
+
104
+ Returns:
105
+ {
106
+ "branch": "main",
107
+ "commit": "d78e458abc123",
108
+ "remote": "https://github.com/user/repo.git",
109
+ "dirty": False,
110
+ "last_commit_date": "2026-01-08T12:34:56Z"
111
+ }
112
+
113
+ All fields present. Non-git repos return sensible defaults.
114
+ """
115
+ if self._git_info_cache is not None:
116
+ return self._git_info_cache
117
+
118
+ result: dict[str, Any] = {
119
+ "branch": get_current_branch(self.repo_path),
120
+ "commit": get_current_commit(self.repo_path),
121
+ "remote": get_git_remote(self.repo_path),
122
+ "dirty": is_git_dirty(self.repo_path),
123
+ "last_commit_date": get_last_commit_date(self.repo_path),
124
+ }
125
+
126
+ self._git_info_cache = result
127
+ return result
128
+
129
+ def is_monorepo(self) -> bool:
130
+ """
131
+ Detect if this is a monorepo structure.
132
+
133
+ Looks for:
134
+ - Multiple package.json files (npm/yarn monorepo)
135
+ - Multiple pyproject.toml files (Python monorepo)
136
+ - workspaces field in package.json
137
+
138
+ Returns:
139
+ True if monorepo structure detected, False otherwise.
140
+ """
141
+ return _detect_monorepo(self.repo_path)
142
+
143
+ def get_monorepo_project(self) -> str | None:
144
+ """
145
+ If monorepo, identify which project we're in.
146
+
147
+ Scans up from current directory to find workspace marker,
148
+ then identifies the project subdirectory.
149
+
150
+ Returns:
151
+ Project name (e.g., "packages/claude-plugin") or None if not in monorepo.
152
+ """
153
+ return _get_monorepo_project(self.repo_path)
154
+
155
+
156
+ # Module-level functions for git operations
157
+
158
+
159
+ def compute_hash_inputs(path: str, remote: str | None, inode: int) -> str:
160
+ """
161
+ Combine inputs into stable hash string.
162
+
163
+ Args:
164
+ path: Absolute repository path
165
+ remote: Git remote URL (optional)
166
+ inode: File system inode
167
+
168
+ Returns:
169
+ Combined hash input string
170
+ """
171
+ # Order matters for determinism
172
+ parts = [
173
+ f"path:{path}",
174
+ f"remote:{remote or 'none'}",
175
+ f"inode:{inode}",
176
+ ]
177
+ return "|".join(parts)
178
+
179
+
180
+ def get_git_remote(repo_path: Path | None = None) -> str | None:
181
+ """
182
+ Get primary git remote URL.
183
+
184
+ Attempts to get 'origin' remote, falls back to first available remote.
185
+
186
+ Args:
187
+ repo_path: Repository path. Defaults to current directory.
188
+
189
+ Returns:
190
+ Remote URL string or None if not a git repo.
191
+ """
192
+ if repo_path is None:
193
+ repo_path = Path.cwd()
194
+ else:
195
+ repo_path = Path(repo_path)
196
+
197
+ try:
198
+ # Try to get origin remote first
199
+ result = subprocess.run(
200
+ ["git", "-C", str(repo_path), "config", "--get", "remote.origin.url"],
201
+ capture_output=True,
202
+ text=True,
203
+ timeout=5,
204
+ )
205
+
206
+ if result.returncode == 0 and result.stdout.strip():
207
+ return result.stdout.strip()
208
+
209
+ # Fall back to first available remote
210
+ result = subprocess.run(
211
+ ["git", "-C", str(repo_path), "remote", "get-url", "origin"],
212
+ capture_output=True,
213
+ text=True,
214
+ timeout=5,
215
+ )
216
+
217
+ if result.returncode == 0 and result.stdout.strip():
218
+ return result.stdout.strip()
219
+
220
+ return None
221
+ except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
222
+ logger.debug(f"Failed to get git remote for {repo_path}")
223
+ return None
224
+
225
+
226
+ def get_current_branch(repo_path: Path | None = None) -> str | None:
227
+ """
228
+ Get current git branch name.
229
+
230
+ Args:
231
+ repo_path: Repository path. Defaults to current directory.
232
+
233
+ Returns:
234
+ Branch name (e.g., "main") or None if not a git repo.
235
+ """
236
+ if repo_path is None:
237
+ repo_path = Path.cwd()
238
+ else:
239
+ repo_path = Path(repo_path)
240
+
241
+ try:
242
+ result = subprocess.run(
243
+ ["git", "-C", str(repo_path), "rev-parse", "--abbrev-ref", "HEAD"],
244
+ capture_output=True,
245
+ text=True,
246
+ timeout=5,
247
+ )
248
+
249
+ if result.returncode == 0:
250
+ branch = result.stdout.strip()
251
+ return branch if branch else None
252
+
253
+ return None
254
+ except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
255
+ logger.debug(f"Failed to get current branch for {repo_path}")
256
+ return None
257
+
258
+
259
+ def get_current_commit(repo_path: Path | None = None) -> str | None:
260
+ """
261
+ Get current commit SHA (short form, 7 chars).
262
+
263
+ Args:
264
+ repo_path: Repository path. Defaults to current directory.
265
+
266
+ Returns:
267
+ Short commit SHA (e.g., "d78e458") or None if not a git repo.
268
+ """
269
+ if repo_path is None:
270
+ repo_path = Path.cwd()
271
+ else:
272
+ repo_path = Path(repo_path)
273
+
274
+ try:
275
+ result = subprocess.run(
276
+ [
277
+ "git",
278
+ "-C",
279
+ str(repo_path),
280
+ "rev-parse",
281
+ "--short=7",
282
+ "HEAD",
283
+ ],
284
+ capture_output=True,
285
+ text=True,
286
+ timeout=5,
287
+ )
288
+
289
+ if result.returncode == 0:
290
+ commit = result.stdout.strip()
291
+ return commit if commit else None
292
+
293
+ return None
294
+ except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
295
+ logger.debug(f"Failed to get current commit for {repo_path}")
296
+ return None
297
+
298
+
299
+ def is_git_dirty(repo_path: Path | None = None) -> bool:
300
+ """
301
+ Check if repo has uncommitted changes.
302
+
303
+ Args:
304
+ repo_path: Repository path. Defaults to current directory.
305
+
306
+ Returns:
307
+ True if repo has uncommitted changes, False if clean or not a git repo.
308
+ """
309
+ if repo_path is None:
310
+ repo_path = Path.cwd()
311
+ else:
312
+ repo_path = Path(repo_path)
313
+
314
+ try:
315
+ # Check for staged or unstaged changes
316
+ result = subprocess.run(
317
+ ["git", "-C", str(repo_path), "status", "--porcelain"],
318
+ capture_output=True,
319
+ text=True,
320
+ timeout=5,
321
+ )
322
+
323
+ if result.returncode == 0:
324
+ # If there's any output, repo is dirty
325
+ return bool(result.stdout.strip())
326
+
327
+ return False
328
+ except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
329
+ logger.debug(f"Failed to check git dirty status for {repo_path}")
330
+ return False
331
+
332
+
333
+ def get_last_commit_date(repo_path: Path | None = None) -> str | None:
334
+ """
335
+ Get last commit timestamp in ISO 8601 format.
336
+
337
+ Args:
338
+ repo_path: Repository path. Defaults to current directory.
339
+
340
+ Returns:
341
+ ISO 8601 timestamp (e.g., "2026-01-08T12:34:56Z") or None if not a git repo.
342
+ """
343
+ if repo_path is None:
344
+ repo_path = Path.cwd()
345
+ else:
346
+ repo_path = Path(repo_path)
347
+
348
+ try:
349
+ result = subprocess.run(
350
+ [
351
+ "git",
352
+ "-C",
353
+ str(repo_path),
354
+ "log",
355
+ "-1",
356
+ "--format=%ci",
357
+ "HEAD",
358
+ ],
359
+ capture_output=True,
360
+ text=True,
361
+ timeout=5,
362
+ )
363
+
364
+ if result.returncode == 0:
365
+ commit_date_str = result.stdout.strip()
366
+ if commit_date_str:
367
+ # Parse ISO format from git and ensure UTC timezone marker
368
+ try:
369
+ # Git returns: "2026-01-08 12:34:56 +0000"
370
+ # Parse by replacing space with T and removing timezone
371
+ dt_str = commit_date_str.split("+")[0].strip().replace(" ", "T")
372
+ dt = datetime.fromisoformat(dt_str)
373
+ # Format as UTC ISO 8601
374
+ return dt.strftime("%Y-%m-%dT%H:%M:%SZ")
375
+ except (ValueError, AttributeError, IndexError):
376
+ # Fallback: return as-is if parsing fails
377
+ return commit_date_str
378
+
379
+ return None
380
+ except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
381
+ logger.debug(f"Failed to get last commit date for {repo_path}")
382
+ return None
383
+
384
+
385
+ def get_inode(path: Path) -> int:
386
+ """
387
+ Get file system inode for unique identification.
388
+
389
+ The inode is a unique identifier on the file system.
390
+ Different mount points can have different inodes for the same repository.
391
+
392
+ Args:
393
+ path: File system path.
394
+
395
+ Returns:
396
+ Inode number.
397
+
398
+ Raises:
399
+ OSError: If stat() fails.
400
+ """
401
+ try:
402
+ st = os.stat(path)
403
+ return st.st_ino
404
+ except OSError as e:
405
+ logger.error(f"Failed to get inode for {path}: {e}")
406
+ raise
407
+
408
+
409
+ # Monorepo detection helpers
410
+
411
+
412
+ def _detect_monorepo(repo_path: Path) -> bool:
413
+ """
414
+ Detect if repository is a monorepo.
415
+
416
+ Looks for:
417
+ - Multiple pyproject.toml files (Python monorepo)
418
+ - Multiple package.json files (npm/yarn monorepo)
419
+ - workspaces field in package.json
420
+
421
+ Args:
422
+ repo_path: Repository path.
423
+
424
+ Returns:
425
+ True if monorepo structure detected.
426
+ """
427
+ try:
428
+ # Check for Python monorepo (multiple pyproject.toml)
429
+ pyproject_files = list(repo_path.glob("**/pyproject.toml"))
430
+ if len(pyproject_files) > 1:
431
+ return True
432
+
433
+ # Check for npm monorepo (multiple package.json)
434
+ package_files = list(repo_path.glob("**/package.json"))
435
+ if len(package_files) > 1:
436
+ return True
437
+
438
+ # Check for workspaces in root package.json
439
+ root_package = repo_path / "package.json"
440
+ if root_package.exists():
441
+ try:
442
+ import json
443
+
444
+ with open(root_package) as f:
445
+ data = json.load(f)
446
+ if "workspaces" in data:
447
+ return True
448
+ except (json.JSONDecodeError, OSError):
449
+ pass
450
+
451
+ return False
452
+ except OSError:
453
+ logger.debug(f"Error detecting monorepo at {repo_path}")
454
+ return False
455
+
456
+
457
+ def _get_monorepo_project(repo_path: Path) -> str | None:
458
+ """
459
+ Identify which project we're in within a monorepo.
460
+
461
+ Scans up from repo_path to find workspace marker (pyproject.toml, package.json),
462
+ then returns relative path from workspace root to repo.
463
+
464
+ Args:
465
+ repo_path: Repository path (or subdirectory within monorepo).
466
+
467
+ Returns:
468
+ Relative path from monorepo root to project (e.g., "packages/claude-plugin")
469
+ or None if not in monorepo.
470
+ """
471
+ try:
472
+ # First, find monorepo root by scanning up from repo_path for .git
473
+ current = repo_path.resolve()
474
+ monorepo_root = None
475
+
476
+ while current != current.parent:
477
+ if (current / ".git").exists():
478
+ monorepo_root = current
479
+ break
480
+ current = current.parent
481
+
482
+ if monorepo_root is None:
483
+ # Not in a git repo or .git not found - not a monorepo context
484
+ return None
485
+
486
+ # Now check if this is actually a monorepo
487
+ if not _detect_monorepo(monorepo_root):
488
+ return None
489
+
490
+ # Find the project directory (first ancestor with pyproject.toml or package.json)
491
+ current = repo_path.resolve()
492
+ while current != current.parent:
493
+ if (current / "pyproject.toml").exists() or (
494
+ current / "package.json"
495
+ ).exists():
496
+ # Found project root, return relative path from monorepo root
497
+ try:
498
+ rel_path = current.relative_to(monorepo_root)
499
+ result = str(rel_path)
500
+ return result if result != "." else None
501
+ except ValueError:
502
+ return None
503
+
504
+ if current == monorepo_root:
505
+ # Reached monorepo root without finding project marker
506
+ break
507
+ current = current.parent
508
+
509
+ return None
510
+ except (OSError, ValueError):
511
+ logger.debug(f"Error identifying monorepo project at {repo_path}")
512
+ return None