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.
- htmlgraph/.htmlgraph/.session-warning-state.json +6 -0
- htmlgraph/.htmlgraph/agents.json +72 -0
- htmlgraph/.htmlgraph/htmlgraph.db +0 -0
- htmlgraph/__init__.py +51 -1
- htmlgraph/__init__.pyi +123 -0
- htmlgraph/agent_detection.py +26 -10
- htmlgraph/agent_registry.py +2 -1
- htmlgraph/analytics/__init__.py +8 -1
- htmlgraph/analytics/cli.py +86 -20
- htmlgraph/analytics/cost_analyzer.py +391 -0
- htmlgraph/analytics/cost_monitor.py +664 -0
- htmlgraph/analytics/cost_reporter.py +675 -0
- htmlgraph/analytics/cross_session.py +617 -0
- htmlgraph/analytics/dependency.py +10 -6
- htmlgraph/analytics/pattern_learning.py +771 -0
- htmlgraph/analytics/session_graph.py +707 -0
- htmlgraph/analytics/strategic/__init__.py +80 -0
- htmlgraph/analytics/strategic/cost_optimizer.py +611 -0
- htmlgraph/analytics/strategic/pattern_detector.py +876 -0
- htmlgraph/analytics/strategic/preference_manager.py +709 -0
- htmlgraph/analytics/strategic/suggestion_engine.py +747 -0
- htmlgraph/analytics/work_type.py +67 -27
- htmlgraph/analytics_index.py +53 -20
- htmlgraph/api/__init__.py +3 -0
- htmlgraph/api/cost_alerts_websocket.py +416 -0
- htmlgraph/api/main.py +2498 -0
- htmlgraph/api/static/htmx.min.js +1 -0
- htmlgraph/api/static/style-redesign.css +1344 -0
- htmlgraph/api/static/style.css +1079 -0
- htmlgraph/api/templates/dashboard-redesign.html +1366 -0
- htmlgraph/api/templates/dashboard.html +794 -0
- htmlgraph/api/templates/partials/activity-feed-hierarchical.html +326 -0
- htmlgraph/api/templates/partials/activity-feed.html +1100 -0
- htmlgraph/api/templates/partials/agents-redesign.html +317 -0
- htmlgraph/api/templates/partials/agents.html +317 -0
- htmlgraph/api/templates/partials/event-traces.html +373 -0
- htmlgraph/api/templates/partials/features-kanban-redesign.html +509 -0
- htmlgraph/api/templates/partials/features.html +578 -0
- htmlgraph/api/templates/partials/metrics-redesign.html +346 -0
- htmlgraph/api/templates/partials/metrics.html +346 -0
- htmlgraph/api/templates/partials/orchestration-redesign.html +443 -0
- htmlgraph/api/templates/partials/orchestration.html +198 -0
- htmlgraph/api/templates/partials/spawners.html +375 -0
- htmlgraph/api/templates/partials/work-items.html +613 -0
- htmlgraph/api/websocket.py +538 -0
- htmlgraph/archive/__init__.py +24 -0
- htmlgraph/archive/bloom.py +234 -0
- htmlgraph/archive/fts.py +297 -0
- htmlgraph/archive/manager.py +583 -0
- htmlgraph/archive/search.py +244 -0
- htmlgraph/atomic_ops.py +560 -0
- htmlgraph/attribute_index.py +2 -1
- htmlgraph/bounded_paths.py +539 -0
- htmlgraph/builders/base.py +57 -2
- htmlgraph/builders/bug.py +19 -3
- htmlgraph/builders/chore.py +19 -3
- htmlgraph/builders/epic.py +19 -3
- htmlgraph/builders/feature.py +27 -3
- htmlgraph/builders/insight.py +2 -1
- htmlgraph/builders/metric.py +2 -1
- htmlgraph/builders/pattern.py +2 -1
- htmlgraph/builders/phase.py +19 -3
- htmlgraph/builders/spike.py +29 -3
- htmlgraph/builders/track.py +42 -1
- htmlgraph/cigs/__init__.py +81 -0
- htmlgraph/cigs/autonomy.py +385 -0
- htmlgraph/cigs/cost.py +475 -0
- htmlgraph/cigs/messages_basic.py +472 -0
- htmlgraph/cigs/messaging.py +365 -0
- htmlgraph/cigs/models.py +771 -0
- htmlgraph/cigs/pattern_storage.py +427 -0
- htmlgraph/cigs/patterns.py +503 -0
- htmlgraph/cigs/posttool_analyzer.py +234 -0
- htmlgraph/cigs/reporter.py +818 -0
- htmlgraph/cigs/tracker.py +317 -0
- htmlgraph/cli/.htmlgraph/.session-warning-state.json +6 -0
- htmlgraph/cli/.htmlgraph/agents.json +72 -0
- htmlgraph/cli/.htmlgraph/htmlgraph.db +0 -0
- htmlgraph/cli/__init__.py +42 -0
- htmlgraph/cli/__main__.py +6 -0
- htmlgraph/cli/analytics.py +1424 -0
- htmlgraph/cli/base.py +685 -0
- htmlgraph/cli/constants.py +206 -0
- htmlgraph/cli/core.py +954 -0
- htmlgraph/cli/main.py +147 -0
- htmlgraph/cli/models.py +475 -0
- htmlgraph/cli/templates/__init__.py +1 -0
- htmlgraph/cli/templates/cost_dashboard.py +399 -0
- htmlgraph/cli/work/__init__.py +239 -0
- htmlgraph/cli/work/browse.py +115 -0
- htmlgraph/cli/work/features.py +568 -0
- htmlgraph/cli/work/orchestration.py +676 -0
- htmlgraph/cli/work/report.py +728 -0
- htmlgraph/cli/work/sessions.py +466 -0
- htmlgraph/cli/work/snapshot.py +559 -0
- htmlgraph/cli/work/tracks.py +486 -0
- htmlgraph/cli_commands/__init__.py +1 -0
- htmlgraph/cli_commands/feature.py +195 -0
- htmlgraph/cli_framework.py +115 -0
- htmlgraph/collections/__init__.py +2 -0
- htmlgraph/collections/base.py +197 -14
- htmlgraph/collections/bug.py +2 -1
- htmlgraph/collections/chore.py +2 -1
- htmlgraph/collections/epic.py +2 -1
- htmlgraph/collections/feature.py +2 -1
- htmlgraph/collections/insight.py +2 -1
- htmlgraph/collections/metric.py +2 -1
- htmlgraph/collections/pattern.py +2 -1
- htmlgraph/collections/phase.py +2 -1
- htmlgraph/collections/session.py +194 -0
- htmlgraph/collections/spike.py +13 -2
- htmlgraph/collections/task_delegation.py +241 -0
- htmlgraph/collections/todo.py +14 -1
- htmlgraph/collections/traces.py +487 -0
- htmlgraph/config/cost_models.json +56 -0
- htmlgraph/config.py +190 -0
- htmlgraph/context_analytics.py +2 -1
- htmlgraph/converter.py +116 -7
- htmlgraph/cost_analysis/__init__.py +5 -0
- htmlgraph/cost_analysis/analyzer.py +438 -0
- htmlgraph/dashboard.html +2246 -248
- htmlgraph/dashboard.html.backup +6592 -0
- htmlgraph/dashboard.html.bak +7181 -0
- htmlgraph/dashboard.html.bak2 +7231 -0
- htmlgraph/dashboard.html.bak3 +7232 -0
- htmlgraph/db/__init__.py +38 -0
- htmlgraph/db/queries.py +790 -0
- htmlgraph/db/schema.py +1788 -0
- htmlgraph/decorators.py +317 -0
- htmlgraph/dependency_models.py +2 -1
- htmlgraph/deploy.py +26 -27
- htmlgraph/docs/API_REFERENCE.md +841 -0
- htmlgraph/docs/HTTP_API.md +750 -0
- htmlgraph/docs/INTEGRATION_GUIDE.md +752 -0
- htmlgraph/docs/ORCHESTRATION_PATTERNS.md +717 -0
- htmlgraph/docs/README.md +532 -0
- htmlgraph/docs/__init__.py +77 -0
- htmlgraph/docs/docs_version.py +55 -0
- htmlgraph/docs/metadata.py +93 -0
- htmlgraph/docs/migrations.py +232 -0
- htmlgraph/docs/template_engine.py +143 -0
- htmlgraph/docs/templates/_sections/cli_reference.md.j2 +52 -0
- htmlgraph/docs/templates/_sections/core_concepts.md.j2 +29 -0
- htmlgraph/docs/templates/_sections/sdk_basics.md.j2 +69 -0
- htmlgraph/docs/templates/base_agents.md.j2 +78 -0
- htmlgraph/docs/templates/example_user_override.md.j2 +47 -0
- htmlgraph/docs/version_check.py +163 -0
- htmlgraph/edge_index.py +2 -1
- htmlgraph/error_handler.py +544 -0
- htmlgraph/event_log.py +86 -37
- htmlgraph/event_migration.py +2 -1
- htmlgraph/file_watcher.py +12 -8
- htmlgraph/find_api.py +2 -1
- htmlgraph/git_events.py +67 -9
- htmlgraph/hooks/.htmlgraph/.session-warning-state.json +6 -0
- htmlgraph/hooks/.htmlgraph/agents.json +72 -0
- htmlgraph/hooks/.htmlgraph/index.sqlite +0 -0
- htmlgraph/hooks/__init__.py +8 -0
- htmlgraph/hooks/bootstrap.py +169 -0
- htmlgraph/hooks/cigs_pretool_enforcer.py +354 -0
- htmlgraph/hooks/concurrent_sessions.py +208 -0
- htmlgraph/hooks/context.py +350 -0
- htmlgraph/hooks/drift_handler.py +525 -0
- htmlgraph/hooks/event_tracker.py +790 -99
- htmlgraph/hooks/git_commands.py +175 -0
- htmlgraph/hooks/installer.py +5 -1
- htmlgraph/hooks/orchestrator.py +327 -76
- htmlgraph/hooks/orchestrator_reflector.py +31 -4
- htmlgraph/hooks/post_tool_use_failure.py +32 -7
- htmlgraph/hooks/post_tool_use_handler.py +257 -0
- htmlgraph/hooks/posttooluse.py +92 -19
- htmlgraph/hooks/pretooluse.py +527 -7
- htmlgraph/hooks/prompt_analyzer.py +637 -0
- htmlgraph/hooks/session_handler.py +668 -0
- htmlgraph/hooks/session_summary.py +395 -0
- htmlgraph/hooks/state_manager.py +504 -0
- htmlgraph/hooks/subagent_detection.py +202 -0
- htmlgraph/hooks/subagent_stop.py +369 -0
- htmlgraph/hooks/task_enforcer.py +99 -4
- htmlgraph/hooks/validator.py +212 -91
- htmlgraph/ids.py +2 -1
- htmlgraph/learning.py +125 -100
- htmlgraph/mcp_server.py +2 -1
- htmlgraph/models.py +217 -18
- htmlgraph/operations/README.md +62 -0
- htmlgraph/operations/__init__.py +79 -0
- htmlgraph/operations/analytics.py +339 -0
- htmlgraph/operations/bootstrap.py +289 -0
- htmlgraph/operations/events.py +244 -0
- htmlgraph/operations/fastapi_server.py +231 -0
- htmlgraph/operations/hooks.py +350 -0
- htmlgraph/operations/initialization.py +597 -0
- htmlgraph/operations/initialization.py.backup +228 -0
- htmlgraph/operations/server.py +303 -0
- htmlgraph/orchestration/__init__.py +58 -0
- htmlgraph/orchestration/claude_launcher.py +179 -0
- htmlgraph/orchestration/command_builder.py +72 -0
- htmlgraph/orchestration/headless_spawner.py +281 -0
- htmlgraph/orchestration/live_events.py +377 -0
- htmlgraph/orchestration/model_selection.py +327 -0
- htmlgraph/orchestration/plugin_manager.py +140 -0
- htmlgraph/orchestration/prompts.py +137 -0
- htmlgraph/orchestration/spawner_event_tracker.py +383 -0
- htmlgraph/orchestration/spawners/__init__.py +16 -0
- htmlgraph/orchestration/spawners/base.py +194 -0
- htmlgraph/orchestration/spawners/claude.py +173 -0
- htmlgraph/orchestration/spawners/codex.py +435 -0
- htmlgraph/orchestration/spawners/copilot.py +294 -0
- htmlgraph/orchestration/spawners/gemini.py +471 -0
- htmlgraph/orchestration/subprocess_runner.py +36 -0
- htmlgraph/{orchestration.py → orchestration/task_coordination.py} +16 -8
- htmlgraph/orchestration.md +563 -0
- htmlgraph/orchestrator-system-prompt-optimized.txt +863 -0
- htmlgraph/orchestrator.py +2 -1
- htmlgraph/orchestrator_config.py +357 -0
- htmlgraph/orchestrator_mode.py +115 -4
- htmlgraph/parallel.py +2 -1
- htmlgraph/parser.py +86 -6
- htmlgraph/path_query.py +608 -0
- htmlgraph/pattern_matcher.py +636 -0
- htmlgraph/pydantic_models.py +476 -0
- htmlgraph/quality_gates.py +350 -0
- htmlgraph/query_builder.py +2 -1
- htmlgraph/query_composer.py +509 -0
- htmlgraph/reflection.py +443 -0
- htmlgraph/refs.py +344 -0
- htmlgraph/repo_hash.py +512 -0
- htmlgraph/repositories/__init__.py +292 -0
- htmlgraph/repositories/analytics_repository.py +455 -0
- htmlgraph/repositories/analytics_repository_standard.py +628 -0
- htmlgraph/repositories/feature_repository.py +581 -0
- htmlgraph/repositories/feature_repository_htmlfile.py +668 -0
- htmlgraph/repositories/feature_repository_memory.py +607 -0
- htmlgraph/repositories/feature_repository_sqlite.py +858 -0
- htmlgraph/repositories/filter_service.py +620 -0
- htmlgraph/repositories/filter_service_standard.py +445 -0
- htmlgraph/repositories/shared_cache.py +621 -0
- htmlgraph/repositories/shared_cache_memory.py +395 -0
- htmlgraph/repositories/track_repository.py +552 -0
- htmlgraph/repositories/track_repository_htmlfile.py +619 -0
- htmlgraph/repositories/track_repository_memory.py +508 -0
- htmlgraph/repositories/track_repository_sqlite.py +711 -0
- htmlgraph/sdk/__init__.py +398 -0
- htmlgraph/sdk/__init__.pyi +14 -0
- htmlgraph/sdk/analytics/__init__.py +19 -0
- htmlgraph/sdk/analytics/engine.py +155 -0
- htmlgraph/sdk/analytics/helpers.py +178 -0
- htmlgraph/sdk/analytics/registry.py +109 -0
- htmlgraph/sdk/base.py +484 -0
- htmlgraph/sdk/constants.py +216 -0
- htmlgraph/sdk/core.pyi +308 -0
- htmlgraph/sdk/discovery.py +120 -0
- htmlgraph/sdk/help/__init__.py +12 -0
- htmlgraph/sdk/help/mixin.py +699 -0
- htmlgraph/sdk/mixins/__init__.py +15 -0
- htmlgraph/sdk/mixins/attribution.py +113 -0
- htmlgraph/sdk/mixins/mixin.py +410 -0
- htmlgraph/sdk/operations/__init__.py +12 -0
- htmlgraph/sdk/operations/mixin.py +427 -0
- htmlgraph/sdk/orchestration/__init__.py +17 -0
- htmlgraph/sdk/orchestration/coordinator.py +203 -0
- htmlgraph/sdk/orchestration/spawner.py +204 -0
- htmlgraph/sdk/planning/__init__.py +19 -0
- htmlgraph/sdk/planning/bottlenecks.py +93 -0
- htmlgraph/sdk/planning/mixin.py +211 -0
- htmlgraph/sdk/planning/parallel.py +186 -0
- htmlgraph/sdk/planning/queue.py +210 -0
- htmlgraph/sdk/planning/recommendations.py +87 -0
- htmlgraph/sdk/planning/smart_planning.py +319 -0
- htmlgraph/sdk/session/__init__.py +19 -0
- htmlgraph/sdk/session/continuity.py +57 -0
- htmlgraph/sdk/session/handoff.py +110 -0
- htmlgraph/sdk/session/info.py +309 -0
- htmlgraph/sdk/session/manager.py +103 -0
- htmlgraph/sdk/strategic/__init__.py +26 -0
- htmlgraph/sdk/strategic/mixin.py +563 -0
- htmlgraph/server.py +295 -107
- htmlgraph/session_hooks.py +300 -0
- htmlgraph/session_manager.py +285 -3
- htmlgraph/session_registry.py +587 -0
- htmlgraph/session_state.py +436 -0
- htmlgraph/session_warning.py +2 -1
- htmlgraph/sessions/__init__.py +23 -0
- htmlgraph/sessions/handoff.py +756 -0
- htmlgraph/system_prompts.py +450 -0
- htmlgraph/templates/orchestration-view.html +350 -0
- htmlgraph/track_builder.py +33 -1
- htmlgraph/track_manager.py +38 -0
- htmlgraph/transcript.py +18 -5
- htmlgraph/validation.py +115 -0
- htmlgraph/watch.py +2 -1
- htmlgraph/work_type_utils.py +2 -1
- {htmlgraph-0.20.1.data → htmlgraph-0.27.5.data}/data/htmlgraph/dashboard.html +2246 -248
- {htmlgraph-0.20.1.dist-info → htmlgraph-0.27.5.dist-info}/METADATA +95 -64
- htmlgraph-0.27.5.dist-info/RECORD +337 -0
- {htmlgraph-0.20.1.dist-info → htmlgraph-0.27.5.dist-info}/entry_points.txt +1 -1
- htmlgraph/cli.py +0 -4839
- htmlgraph/sdk.py +0 -2359
- htmlgraph-0.20.1.dist-info/RECORD +0 -118
- {htmlgraph-0.20.1.data → htmlgraph-0.27.5.data}/data/htmlgraph/styles.css +0 -0
- {htmlgraph-0.20.1.data → htmlgraph-0.27.5.data}/data/htmlgraph/templates/AGENTS.md.template +0 -0
- {htmlgraph-0.20.1.data → htmlgraph-0.27.5.data}/data/htmlgraph/templates/CLAUDE.md.template +0 -0
- {htmlgraph-0.20.1.data → htmlgraph-0.27.5.data}/data/htmlgraph/templates/GEMINI.md.template +0 -0
- {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
|