attocode-code-intel 0.1.0__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.
- attocode_code_intel-0.1.0.dist-info/METADATA +221 -0
- attocode_code_intel-0.1.0.dist-info/RECORD +345 -0
- attocode_code_intel-0.1.0.dist-info/WHEEL +4 -0
- attocode_code_intel-0.1.0.dist-info/entry_points.txt +2 -0
- attocode_code_intel-0.1.0.dist-info/licenses/LICENSE +21 -0
- attocode_intel/GUIDELINES.md +432 -0
- attocode_intel/__init__.py +52 -0
- attocode_intel/_internal/__init__.py +1 -0
- attocode_intel/_internal/errors/__init__.py +126 -0
- attocode_intel/_internal/integrations/__init__.py +1 -0
- attocode_intel/_internal/integrations/context/__init__.py +1 -0
- attocode_intel/_internal/integrations/context/architecture_drift.py +354 -0
- attocode_intel/_internal/integrations/context/ast_chunker.py +202 -0
- attocode_intel/_internal/integrations/context/ast_service.py +1483 -0
- attocode_intel/_internal/integrations/context/blob_oid.py +423 -0
- attocode_intel/_internal/integrations/context/cache_manifest.py +221 -0
- attocode_intel/_internal/integrations/context/cas.py +466 -0
- attocode_intel/_internal/integrations/context/code_analyzer.py +528 -0
- attocode_intel/_internal/integrations/context/codebase_ast.py +1461 -0
- attocode_intel/_internal/integrations/context/codebase_context.py +1736 -0
- attocode_intel/_internal/integrations/context/cross_mode.py +263 -0
- attocode_intel/_internal/integrations/context/cross_references.py +616 -0
- attocode_intel/_internal/integrations/context/embedding_rotation.py +597 -0
- attocode_intel/_internal/integrations/context/embeddings.py +282 -0
- attocode_intel/_internal/integrations/context/frecency.py +427 -0
- attocode_intel/_internal/integrations/context/fuzzy.py +320 -0
- attocode_intel/_internal/integrations/context/hierarchical_explorer.py +331 -0
- attocode_intel/_internal/integrations/context/hydration.py +90 -0
- attocode_intel/_internal/integrations/context/index_repair.py +87 -0
- attocode_intel/_internal/integrations/context/index_store.py +586 -0
- attocode_intel/_internal/integrations/context/memory_store.py +602 -0
- attocode_intel/_internal/integrations/context/module_resolution.py +52 -0
- attocode_intel/_internal/integrations/context/query_constraints.py +361 -0
- attocode_intel/_internal/integrations/context/query_history.py +417 -0
- attocode_intel/_internal/integrations/context/reranker.py +351 -0
- attocode_intel/_internal/integrations/context/semantic_search.py +2931 -0
- attocode_intel/_internal/integrations/context/syntax_evidence.py +94 -0
- attocode_intel/_internal/integrations/context/systemone_ranker.py +245 -0
- attocode_intel/_internal/integrations/context/temporal_coupling.py +411 -0
- attocode_intel/_internal/integrations/context/trigram_index.py +820 -0
- attocode_intel/_internal/integrations/context/trigram_regex.py +171 -0
- attocode_intel/_internal/integrations/context/ts_parser.py +2199 -0
- attocode_intel/_internal/integrations/context/vector_store.py +795 -0
- attocode_intel/_internal/integrations/feature_flags/__init__.py +357 -0
- attocode_intel/_internal/integrations/lsp/__init__.py +1 -0
- attocode_intel/_internal/integrations/lsp/client.py +1140 -0
- attocode_intel/_internal/integrations/mcp/__init__.py +1 -0
- attocode_intel/_internal/integrations/mcp/client.py +253 -0
- attocode_intel/_internal/integrations/security/__init__.py +1 -0
- attocode_intel/_internal/integrations/security/custom_rules.py +176 -0
- attocode_intel/_internal/integrations/security/dataflow.py +522 -0
- attocode_intel/_internal/integrations/security/dependency_audit.py +303 -0
- attocode_intel/_internal/integrations/security/matcher.py +44 -0
- attocode_intel/_internal/integrations/security/patterns.py +724 -0
- attocode_intel/_internal/integrations/security/scanner.py +351 -0
- attocode_intel/_internal/integrations/utilities/__init__.py +1 -0
- attocode_intel/_internal/integrations/utilities/ignore.py +64 -0
- attocode_intel/_internal/integrations/utilities/token_estimate.py +51 -0
- attocode_intel/_internal/tricks/__init__.py +1 -0
- attocode_intel/_internal/tricks/recursive_context.py +357 -0
- attocode_intel/_internal/types/__init__.py +1 -0
- attocode_intel/_internal/types/events.py +289 -0
- attocode_intel/_internal/types/severity.py +15 -0
- attocode_intel/_shared.py +341 -0
- attocode_intel/analysis_coverage.py +56 -0
- attocode_intel/api/__init__.py +1 -0
- attocode_intel/api/access.py +32 -0
- attocode_intel/api/app.py +247 -0
- attocode_intel/api/auth/__init__.py +128 -0
- attocode_intel/api/auth/api_keys.py +61 -0
- attocode_intel/api/auth/context.py +34 -0
- attocode_intel/api/auth/jwt.py +66 -0
- attocode_intel/api/auth/oauth.py +146 -0
- attocode_intel/api/auth/passwords.py +15 -0
- attocode_intel/api/deps.py +351 -0
- attocode_intel/api/knowledge_bridge.py +13 -0
- attocode_intel/api/mcp_transport.py +51 -0
- attocode_intel/api/middleware.py +411 -0
- attocode_intel/api/models.py +698 -0
- attocode_intel/api/providers/__init__.py +1 -0
- attocode_intel/api/providers/db_provider.py +1106 -0
- attocode_intel/api/providers/local_provider.py +476 -0
- attocode_intel/api/providers/remote_provider.py +740 -0
- attocode_intel/api/repository_source.py +21 -0
- attocode_intel/api/routes/__init__.py +1 -0
- attocode_intel/api/routes/_pin_helper.py +158 -0
- attocode_intel/api/routes/activity.py +148 -0
- attocode_intel/api/routes/adr.py +102 -0
- attocode_intel/api/routes/analysis.py +515 -0
- attocode_intel/api/routes/api_keys.py +170 -0
- attocode_intel/api/routes/auth.py +428 -0
- attocode_intel/api/routes/branches.py +262 -0
- attocode_intel/api/routes/cross_repo_search.py +198 -0
- attocode_intel/api/routes/embeddings.py +386 -0
- attocode_intel/api/routes/files.py +132 -0
- attocode_intel/api/routes/files_v2.py +388 -0
- attocode_intel/api/routes/gc.py +322 -0
- attocode_intel/api/routes/git_v2.py +433 -0
- attocode_intel/api/routes/graph.py +174 -0
- attocode_intel/api/routes/graph_viz.py +160 -0
- attocode_intel/api/routes/health.py +62 -0
- attocode_intel/api/routes/history.py +175 -0
- attocode_intel/api/routes/jobs.py +84 -0
- attocode_intel/api/routes/learning.py +205 -0
- attocode_intel/api/routes/lsp.py +272 -0
- attocode_intel/api/routes/notify.py +623 -0
- attocode_intel/api/routes/operations.py +27 -0
- attocode_intel/api/routes/orgs.py +924 -0
- attocode_intel/api/routes/preferences.py +100 -0
- attocode_intel/api/routes/presence.py +42 -0
- attocode_intel/api/routes/projects.py +116 -0
- attocode_intel/api/routes/repos.py +47 -0
- attocode_intel/api/routes/search.py +180 -0
- attocode_intel/api/routes/snapshots.py +614 -0
- attocode_intel/api/routes/webhooks.py +313 -0
- attocode_intel/api/routes/websocket.py +116 -0
- attocode_intel/api/static/graph.html +239 -0
- attocode_intel/api/utils.py +96 -0
- attocode_intel/artifacts/__init__.py +45 -0
- attocode_intel/artifacts/hashing.py +230 -0
- attocode_intel/artifacts/provenance.py +190 -0
- attocode_intel/artifacts/retrieval_pin.py +159 -0
- attocode_intel/audit.py +59 -0
- attocode_intel/bug_finder.py +206 -0
- attocode_intel/bundle.py +80 -0
- attocode_intel/catalog.py +242 -0
- attocode_intel/change_review.py +75 -0
- attocode_intel/cli.py +3062 -0
- attocode_intel/community.py +63 -0
- attocode_intel/confidence/__init__.py +144 -0
- attocode_intel/confidence/jev.py +234 -0
- attocode_intel/confidence/llm.py +330 -0
- attocode_intel/confidence/redact.py +44 -0
- attocode_intel/confidence/settings.py +74 -0
- attocode_intel/config.py +256 -0
- attocode_intel/crypto.py +31 -0
- attocode_intel/db/__init__.py +7 -0
- attocode_intel/db/base.py +33 -0
- attocode_intel/db/engine.py +61 -0
- attocode_intel/db/models.py +529 -0
- attocode_intel/entrypoint.py +185 -0
- attocode_intel/focused_evidence.py +130 -0
- attocode_intel/freshness.py +154 -0
- attocode_intel/gateway.py +734 -0
- attocode_intel/git/__init__.py +7 -0
- attocode_intel/git/credentials.py +75 -0
- attocode_intel/git/manager.py +493 -0
- attocode_intel/git/models.py +96 -0
- attocode_intel/git/storage.py +98 -0
- attocode_intel/graph_query_parser.py +761 -0
- attocode_intel/helpers.py +712 -0
- attocode_intel/indexing/__init__.py +29 -0
- attocode_intel/indexing/debouncer.py +136 -0
- attocode_intel/indexing/delta_indexer.py +151 -0
- attocode_intel/indexing/full_indexer.py +323 -0
- attocode_intel/indexing/incremental.py +240 -0
- attocode_intel/indexing/parser.py +403 -0
- attocode_intel/indexing/summarizer.py +264 -0
- attocode_intel/installer.py +1896 -0
- attocode_intel/knowledge.py +269 -0
- attocode_intel/local_knowledge.py +77 -0
- attocode_intel/migrations/alembic.ini +36 -0
- attocode_intel/migrations/env.py +71 -0
- attocode_intel/migrations/script.py.mako +27 -0
- attocode_intel/migrations/versions/001_initial_schema.py +219 -0
- attocode_intel/migrations/versions/002_branch_version_and_embeddings.py +61 -0
- attocode_intel/migrations/versions/003_embedding_uniqueness_and_cascade.py +51 -0
- attocode_intel/migrations/versions/004_audit_events.py +38 -0
- attocode_intel/migrations/versions/005_user_preferences.py +30 -0
- attocode_intel/migrations/versions/006_google_oauth.py +27 -0
- attocode_intel/migrations/versions/007_pgvector_embeddings.py +28 -0
- attocode_intel/migrations/versions/008_symbol_references.py +37 -0
- attocode_intel/migrations/versions/009_webhook_secret_encryption.py +25 -0
- attocode_intel/migrations/versions/010_add_commits_table.py +40 -0
- attocode_intel/migrations/versions/011_commit_changed_files.py +25 -0
- attocode_intel/migrations/versions/012_branch_merged_at.py +25 -0
- attocode_intel/migrations/versions/013_revoked_tokens.py +32 -0
- attocode_intel/migrations/versions/014_blame_hunks.py +40 -0
- attocode_intel/migrations/versions/015_commit_file_stats.py +40 -0
- attocode_intel/migrations/versions/016_embedding_provenance_columns.py +179 -0
- attocode_intel/migrations/versions/017_provenance_table.py +102 -0
- attocode_intel/migrations/versions/018_repo_snapshots.py +178 -0
- attocode_intel/migrations/versions/019_symref_caller.py +42 -0
- attocode_intel/migrations/versions/020_shared_knowledge.py +29 -0
- attocode_intel/onboarding.py +258 -0
- attocode_intel/output.py +178 -0
- attocode_intel/precision.py +133 -0
- attocode_intel/presence.py +160 -0
- attocode_intel/probe.py +110 -0
- attocode_intel/project_dir.py +97 -0
- attocode_intel/pubsub.py +113 -0
- attocode_intel/query_ranking.py +242 -0
- attocode_intel/readiness.py +972 -0
- attocode_intel/redis.py +51 -0
- attocode_intel/reference_pages.py +60 -0
- attocode_intel/remote.py +131 -0
- attocode_intel/repo_ranker.py +246 -0
- attocode_intel/request_context.py +82 -0
- attocode_intel/rules/__init__.py +28 -0
- attocode_intel/rules/ci.py +320 -0
- attocode_intel/rules/combinators.py +293 -0
- attocode_intel/rules/enricher.py +127 -0
- attocode_intel/rules/evolution.py +452 -0
- attocode_intel/rules/executor.py +531 -0
- attocode_intel/rules/filters/__init__.py +5 -0
- attocode_intel/rules/filters/pipeline.py +151 -0
- attocode_intel/rules/formatter.py +198 -0
- attocode_intel/rules/hygiene.py +242 -0
- attocode_intel/rules/importers/__init__.py +1 -0
- attocode_intel/rules/importers/semgrep.py +382 -0
- attocode_intel/rules/loader.py +292 -0
- attocode_intel/rules/marketplace.py +375 -0
- attocode_intel/rules/metavar.py +181 -0
- attocode_intel/rules/model.py +317 -0
- attocode_intel/rules/packs/__init__.py +1 -0
- attocode_intel/rules/packs/community/_license_templates/Apache-2.0.txt +176 -0
- attocode_intel/rules/packs/community/_license_templates/MIT.txt +21 -0
- attocode_intel/rules/packs/community/bandit-python/LICENSE +176 -0
- attocode_intel/rules/packs/community/bandit-python/NOTICE +13 -0
- attocode_intel/rules/packs/community/bandit-python/PORTING.md +23 -0
- attocode_intel/rules/packs/community/bandit-python/manifest.yaml +14 -0
- attocode_intel/rules/packs/community/bandit-python/rules/B105-hardcoded-password.yaml +23 -0
- attocode_intel/rules/packs/community/bandit-python/rules/B303-weak-md5.yaml +21 -0
- attocode_intel/rules/packs/community/bandit-python/rules/B304-weak-des.yaml +20 -0
- attocode_intel/rules/packs/community/bandit-python/rules/B307-use-of-eval.yaml +22 -0
- attocode_intel/rules/packs/community/bandit-python/rules/B324-weak-sha1.yaml +21 -0
- attocode_intel/rules/packs/community/bandit-python/rules/B501-no-cert-validation.yaml +23 -0
- attocode_intel/rules/packs/community/bandit-python/rules/B602-shell-true.yaml +22 -0
- attocode_intel/rules/packs/community/bandit-python/rules/B608-sqli.yaml +22 -0
- attocode_intel/rules/packs/community/eslint-typescript/LICENSE +21 -0
- attocode_intel/rules/packs/community/eslint-typescript/NOTICE +13 -0
- attocode_intel/rules/packs/community/eslint-typescript/PORTING.md +23 -0
- attocode_intel/rules/packs/community/eslint-typescript/manifest.yaml +14 -0
- attocode_intel/rules/packs/community/eslint-typescript/rules/eqeqeq.yaml +20 -0
- attocode_intel/rules/packs/community/eslint-typescript/rules/no-eval.yaml +22 -0
- attocode_intel/rules/packs/community/eslint-typescript/rules/no-implied-eval.yaml +20 -0
- attocode_intel/rules/packs/community/eslint-typescript/rules/no-new-func.yaml +21 -0
- attocode_intel/rules/packs/community/eslint-typescript/rules/no-throw-literal.yaml +19 -0
- attocode_intel/rules/packs/community/eslint-typescript/rules/no-var.yaml +20 -0
- attocode_intel/rules/packs/community/eslint-typescript/rules/no-with.yaml +18 -0
- attocode_intel/rules/packs/community/gosec-go/LICENSE +176 -0
- attocode_intel/rules/packs/community/gosec-go/NOTICE +13 -0
- attocode_intel/rules/packs/community/gosec-go/PORTING.md +23 -0
- attocode_intel/rules/packs/community/gosec-go/manifest.yaml +14 -0
- attocode_intel/rules/packs/community/gosec-go/rules/G101-hardcoded-credentials.yaml +22 -0
- attocode_intel/rules/packs/community/gosec-go/rules/G102-bind-all-interfaces.yaml +21 -0
- attocode_intel/rules/packs/community/gosec-go/rules/G103-unsafe-block.yaml +21 -0
- attocode_intel/rules/packs/community/gosec-go/rules/G201-sql-format.yaml +21 -0
- attocode_intel/rules/packs/community/gosec-go/rules/G304-file-inclusion.yaml +23 -0
- attocode_intel/rules/packs/community/gosec-go/rules/G401-weak-cipher.yaml +20 -0
- attocode_intel/rules/packs/community/gosec-go/rules/G402-tls-min-version.yaml +20 -0
- attocode_intel/rules/packs/community/gosec-go/rules/G501-md5-import.yaml +21 -0
- attocode_intel/rules/packs/examples/cpp/manifest.yaml +4 -0
- attocode_intel/rules/packs/examples/cpp/rules/security.yaml +90 -0
- attocode_intel/rules/packs/examples/cpp/rules/structural.yaml +42 -0
- attocode_intel/rules/packs/examples/csharp/manifest.yaml +4 -0
- attocode_intel/rules/packs/examples/csharp/rules/security.yaml +69 -0
- attocode_intel/rules/packs/examples/csharp/rules/structural.yaml +42 -0
- attocode_intel/rules/packs/examples/go/manifest.yaml +4 -0
- attocode_intel/rules/packs/examples/go/rules/performance.yaml +136 -0
- attocode_intel/rules/packs/examples/go/rules/security.yaml +48 -0
- attocode_intel/rules/packs/examples/go/rules/structural.yaml +58 -0
- attocode_intel/rules/packs/examples/go/taint/sanitizers.yaml +24 -0
- attocode_intel/rules/packs/examples/go/taint/sinks.yaml +51 -0
- attocode_intel/rules/packs/examples/go/taint/sources.yaml +26 -0
- attocode_intel/rules/packs/examples/java/manifest.yaml +4 -0
- attocode_intel/rules/packs/examples/java/rules/correctness.yaml +55 -0
- attocode_intel/rules/packs/examples/java/rules/structural.yaml +48 -0
- attocode_intel/rules/packs/examples/java/taint/sinks.yaml +37 -0
- attocode_intel/rules/packs/examples/java/taint/sources.yaml +21 -0
- attocode_intel/rules/packs/examples/kotlin/manifest.yaml +4 -0
- attocode_intel/rules/packs/examples/kotlin/rules/correctness.yaml +50 -0
- attocode_intel/rules/packs/examples/kotlin/rules/structural.yaml +38 -0
- attocode_intel/rules/packs/examples/php/manifest.yaml +4 -0
- attocode_intel/rules/packs/examples/php/rules/security.yaml +66 -0
- attocode_intel/rules/packs/examples/php/rules/structural.yaml +41 -0
- attocode_intel/rules/packs/examples/python/manifest.yaml +4 -0
- attocode_intel/rules/packs/examples/python/rules/performance.yaml +90 -0
- attocode_intel/rules/packs/examples/python/rules/structural.yaml +55 -0
- attocode_intel/rules/packs/examples/ruby/manifest.yaml +4 -0
- attocode_intel/rules/packs/examples/ruby/rules/security.yaml +73 -0
- attocode_intel/rules/packs/examples/ruby/rules/structural.yaml +41 -0
- attocode_intel/rules/packs/examples/rust/manifest.yaml +4 -0
- attocode_intel/rules/packs/examples/rust/rules/correctness.yaml +51 -0
- attocode_intel/rules/packs/examples/rust/rules/structural.yaml +49 -0
- attocode_intel/rules/packs/examples/typescript/manifest.yaml +4 -0
- attocode_intel/rules/packs/examples/typescript/rules/performance.yaml +70 -0
- attocode_intel/rules/packs/examples/typescript/rules/structural.yaml +48 -0
- attocode_intel/rules/packs/pack_loader.py +244 -0
- attocode_intel/rules/plugins/__init__.py +1 -0
- attocode_intel/rules/plugins/plugin_loader.py +132 -0
- attocode_intel/rules/profiling.py +332 -0
- attocode_intel/rules/registry.py +213 -0
- attocode_intel/rules/sarif.py +187 -0
- attocode_intel/rules/synthesis.py +420 -0
- attocode_intel/rules/testing.py +326 -0
- attocode_intel/server.py +734 -0
- attocode_intel/service.py +3416 -0
- attocode_intel/sharing.py +57 -0
- attocode_intel/storage/__init__.py +6 -0
- attocode_intel/storage/branch_overlay.py +352 -0
- attocode_intel/storage/content_store.py +220 -0
- attocode_intel/storage/dependency_store.py +143 -0
- attocode_intel/storage/diff_engine.py +187 -0
- attocode_intel/storage/embedding_store.py +594 -0
- attocode_intel/storage/provenance_store.py +137 -0
- attocode_intel/storage/security_scanner_db.py +164 -0
- attocode_intel/storage/symbol_store.py +160 -0
- attocode_intel/structural_evidence.py +144 -0
- attocode_intel/symbol_inspection.py +141 -0
- attocode_intel/symbol_links.py +167 -0
- attocode_intel/telemetry.py +42 -0
- attocode_intel/test_ranking.py +73 -0
- attocode_intel/testing/__init__.py +43 -0
- attocode_intel/testing/fixtures.py +122 -0
- attocode_intel/testing/helpers.py +75 -0
- attocode_intel/testing/mocks.py +99 -0
- attocode_intel/tools/__init__.py +2 -0
- attocode_intel/tools/adr_tools.py +628 -0
- attocode_intel/tools/analysis_tools.py +382 -0
- attocode_intel/tools/composite_tools.py +695 -0
- attocode_intel/tools/cross_mode_tools.py +173 -0
- attocode_intel/tools/dead_code_tools.py +582 -0
- attocode_intel/tools/distill_tools.py +353 -0
- attocode_intel/tools/frecency_tools.py +220 -0
- attocode_intel/tools/fuzzy_tools.py +255 -0
- attocode_intel/tools/history_tools.py +486 -0
- attocode_intel/tools/learning_tools.py +222 -0
- attocode_intel/tools/lsp_tools.py +218 -0
- attocode_intel/tools/maintenance_tools.py +1662 -0
- attocode_intel/tools/navigation_tools.py +220 -0
- attocode_intel/tools/overlay_tools.py +540 -0
- attocode_intel/tools/pin_store.py +432 -0
- attocode_intel/tools/pin_tools.py +266 -0
- attocode_intel/tools/query_constraints_tools.py +262 -0
- attocode_intel/tools/query_history_tools.py +158 -0
- attocode_intel/tools/readiness_tools.py +44 -0
- attocode_intel/tools/rule_tools.py +992 -0
- attocode_intel/tools/search_tools.py +770 -0
- attocode_intel/tools/snapshot_tools.py +624 -0
- attocode_intel/workers/__init__.py +1 -0
- attocode_intel/workers/job_utils.py +111 -0
- attocode_intel/workers/jobs.py +573 -0
- attocode_intel/workers/run.py +30 -0
- attocode_intel/workers/settings.py +61 -0
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
"""Git repository cloning and management.
|
|
2
|
+
|
|
3
|
+
These modules require pygit2 (service mode dependency).
|
|
4
|
+
Import them directly when needed:
|
|
5
|
+
from attocode_intel.git.manager import GitRepoManager
|
|
6
|
+
from attocode_intel.git.models import BranchInfo, DiffEntry, TreeEntry
|
|
7
|
+
"""
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""Git credential management — SSH keys, deploy tokens, PATs."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import os
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from typing import TYPE_CHECKING
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
import pygit2
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(slots=True)
|
|
17
|
+
class Credential:
|
|
18
|
+
"""Resolved credential for git remote operations."""
|
|
19
|
+
|
|
20
|
+
cred_type: str # ssh_key|deploy_token|pat
|
|
21
|
+
value: str
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class CredentialStore:
|
|
25
|
+
"""Resolves credentials for git remote operations.
|
|
26
|
+
|
|
27
|
+
Supports SSH keys, deploy tokens, and personal access tokens.
|
|
28
|
+
Returns pygit2-compatible RemoteCallbacks.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
def __init__(self, ssh_key_path: str = "") -> None:
|
|
32
|
+
self._ssh_key_path = ssh_key_path
|
|
33
|
+
|
|
34
|
+
def resolve(
|
|
35
|
+
self, credential: Credential | None = None
|
|
36
|
+
) -> pygit2.RemoteCallbacks | None:
|
|
37
|
+
"""Resolve a credential into pygit2 RemoteCallbacks.
|
|
38
|
+
|
|
39
|
+
Returns None for public repos (no auth needed).
|
|
40
|
+
"""
|
|
41
|
+
try:
|
|
42
|
+
import pygit2
|
|
43
|
+
except ImportError:
|
|
44
|
+
logger.warning("pygit2 not installed — git operations unavailable")
|
|
45
|
+
return None
|
|
46
|
+
|
|
47
|
+
if credential is None:
|
|
48
|
+
return None
|
|
49
|
+
|
|
50
|
+
callbacks = pygit2.RemoteCallbacks()
|
|
51
|
+
|
|
52
|
+
if credential.cred_type == "ssh_key":
|
|
53
|
+
key_path = credential.value or self._ssh_key_path
|
|
54
|
+
if key_path and os.path.exists(key_path):
|
|
55
|
+
pub_path = f"{key_path}.pub"
|
|
56
|
+
keypair = pygit2.Keypair(
|
|
57
|
+
"git",
|
|
58
|
+
pub_path if os.path.exists(pub_path) else key_path,
|
|
59
|
+
key_path,
|
|
60
|
+
"",
|
|
61
|
+
)
|
|
62
|
+
callbacks.credentials = lambda *_args: keypair
|
|
63
|
+
elif credential.cred_type in ("deploy_token", "pat"):
|
|
64
|
+
user_pass = pygit2.UserPass(credential.value, "x-oauth-basic")
|
|
65
|
+
callbacks.credentials = lambda *_args: user_pass
|
|
66
|
+
|
|
67
|
+
return callbacks
|
|
68
|
+
|
|
69
|
+
def resolve_from_url(self, url: str) -> None:
|
|
70
|
+
"""Try to auto-detect credentials from URL format.
|
|
71
|
+
|
|
72
|
+
Returns None — callers should use explicit credentials.
|
|
73
|
+
"""
|
|
74
|
+
# Public repos don't need auth
|
|
75
|
+
return None
|
|
@@ -0,0 +1,493 @@
|
|
|
1
|
+
"""Git repository manager — clone, fetch, branches, diffs."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import os
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import TYPE_CHECKING
|
|
9
|
+
|
|
10
|
+
from attocode_intel.git.credentials import Credential, CredentialStore
|
|
11
|
+
from attocode_intel.git.models import (
|
|
12
|
+
BlameHunk,
|
|
13
|
+
BranchInfo,
|
|
14
|
+
CommitInfo,
|
|
15
|
+
DiffEntry,
|
|
16
|
+
DiffHunk,
|
|
17
|
+
DiffLine,
|
|
18
|
+
PatchEntry,
|
|
19
|
+
TreeEntry,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
if TYPE_CHECKING:
|
|
23
|
+
pass
|
|
24
|
+
|
|
25
|
+
logger = logging.getLogger(__name__)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class GitRepoManager:
|
|
29
|
+
"""Manages git operations on bare repositories.
|
|
30
|
+
|
|
31
|
+
All operations are designed for bare repos to minimize disk usage.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
def __init__(self, clone_dir: str, ssh_key_path: str = "") -> None:
|
|
35
|
+
self._clone_dir = Path(clone_dir)
|
|
36
|
+
self._cred_store = CredentialStore(ssh_key_path)
|
|
37
|
+
self._path_overrides: dict[str, Path] = {}
|
|
38
|
+
self._clone_dir.mkdir(parents=True, exist_ok=True)
|
|
39
|
+
|
|
40
|
+
def register_path(self, repo_id: str, path: str | Path) -> None:
|
|
41
|
+
"""Register a path override for a repo (e.g. local directory imports)."""
|
|
42
|
+
self._path_overrides[repo_id] = Path(path)
|
|
43
|
+
|
|
44
|
+
def _repo_path(self, repo_id: str) -> Path:
|
|
45
|
+
"""Return the filesystem path for a repo's bare clone."""
|
|
46
|
+
if repo_id in self._path_overrides:
|
|
47
|
+
return self._path_overrides[repo_id]
|
|
48
|
+
return self._clone_dir / f"{repo_id}.git"
|
|
49
|
+
|
|
50
|
+
def clone(
|
|
51
|
+
self,
|
|
52
|
+
url: str,
|
|
53
|
+
repo_id: str,
|
|
54
|
+
credential: Credential | None = None,
|
|
55
|
+
) -> str:
|
|
56
|
+
"""Clone a remote repository as a bare repo.
|
|
57
|
+
|
|
58
|
+
Returns the local clone path.
|
|
59
|
+
"""
|
|
60
|
+
import pygit2
|
|
61
|
+
|
|
62
|
+
dest = self._repo_path(repo_id)
|
|
63
|
+
if dest.exists():
|
|
64
|
+
logger.info("Repo %s already cloned at %s", repo_id, dest)
|
|
65
|
+
return str(dest)
|
|
66
|
+
|
|
67
|
+
callbacks = self._cred_store.resolve(credential)
|
|
68
|
+
logger.info("Cloning %s → %s (bare)", url, dest)
|
|
69
|
+
|
|
70
|
+
pygit2.clone_repository(
|
|
71
|
+
url,
|
|
72
|
+
str(dest),
|
|
73
|
+
bare=True,
|
|
74
|
+
callbacks=callbacks,
|
|
75
|
+
)
|
|
76
|
+
return str(dest)
|
|
77
|
+
|
|
78
|
+
def fetch(
|
|
79
|
+
self,
|
|
80
|
+
repo_id: str,
|
|
81
|
+
credential: Credential | None = None,
|
|
82
|
+
) -> None:
|
|
83
|
+
"""Fetch updates from the remote origin."""
|
|
84
|
+
import pygit2
|
|
85
|
+
|
|
86
|
+
path = self._repo_path(repo_id)
|
|
87
|
+
if not path.exists():
|
|
88
|
+
raise FileNotFoundError(f"Repository {repo_id} not found at {path}")
|
|
89
|
+
|
|
90
|
+
repo = pygit2.Repository(str(path))
|
|
91
|
+
remote = repo.remotes["origin"]
|
|
92
|
+
callbacks = self._cred_store.resolve(credential)
|
|
93
|
+
|
|
94
|
+
logger.info("Fetching updates for %s", repo_id)
|
|
95
|
+
remote.fetch(callbacks=callbacks)
|
|
96
|
+
|
|
97
|
+
def list_branches(self, repo_id: str) -> list[BranchInfo]:
|
|
98
|
+
"""List all branches in the repository."""
|
|
99
|
+
import pygit2
|
|
100
|
+
|
|
101
|
+
path = self._repo_path(repo_id)
|
|
102
|
+
if not path.exists():
|
|
103
|
+
raise FileNotFoundError(f"Repository {repo_id} not found")
|
|
104
|
+
|
|
105
|
+
repo = pygit2.Repository(str(path))
|
|
106
|
+
default_branch = "main"
|
|
107
|
+
|
|
108
|
+
# Try to detect default branch from HEAD
|
|
109
|
+
try:
|
|
110
|
+
head_ref = repo.references.get("HEAD")
|
|
111
|
+
if head_ref and head_ref.target:
|
|
112
|
+
target = str(head_ref.target)
|
|
113
|
+
if target.startswith("refs/heads/"):
|
|
114
|
+
default_branch = target[len("refs/heads/"):]
|
|
115
|
+
except Exception:
|
|
116
|
+
pass
|
|
117
|
+
|
|
118
|
+
branches = []
|
|
119
|
+
for ref_name in repo.references:
|
|
120
|
+
if not ref_name.startswith("refs/heads/") and not ref_name.startswith("refs/remotes/origin/"):
|
|
121
|
+
continue
|
|
122
|
+
|
|
123
|
+
name = ref_name
|
|
124
|
+
if name.startswith("refs/heads/"):
|
|
125
|
+
name = name[len("refs/heads/"):]
|
|
126
|
+
elif name.startswith("refs/remotes/origin/"):
|
|
127
|
+
name = name[len("refs/remotes/origin/"):]
|
|
128
|
+
if name == "HEAD":
|
|
129
|
+
continue
|
|
130
|
+
|
|
131
|
+
ref = repo.references[ref_name]
|
|
132
|
+
try:
|
|
133
|
+
commit = ref.peel(pygit2.Commit)
|
|
134
|
+
except Exception:
|
|
135
|
+
continue
|
|
136
|
+
|
|
137
|
+
branches.append(BranchInfo(
|
|
138
|
+
name=name,
|
|
139
|
+
commit=str(commit.id),
|
|
140
|
+
is_default=(name == default_branch),
|
|
141
|
+
))
|
|
142
|
+
|
|
143
|
+
return branches
|
|
144
|
+
|
|
145
|
+
def get_diff(
|
|
146
|
+
self,
|
|
147
|
+
repo_id: str,
|
|
148
|
+
from_ref: str,
|
|
149
|
+
to_ref: str,
|
|
150
|
+
) -> list[DiffEntry]:
|
|
151
|
+
"""Get the file diff between two refs (branches, commits)."""
|
|
152
|
+
import pygit2
|
|
153
|
+
|
|
154
|
+
path = self._repo_path(repo_id)
|
|
155
|
+
repo = pygit2.Repository(str(path))
|
|
156
|
+
|
|
157
|
+
from_commit = self._resolve_ref(repo, from_ref)
|
|
158
|
+
to_commit = self._resolve_ref(repo, to_ref)
|
|
159
|
+
|
|
160
|
+
diff = repo.diff(from_commit, to_commit)
|
|
161
|
+
entries = []
|
|
162
|
+
for patch in diff:
|
|
163
|
+
delta = patch.delta
|
|
164
|
+
status_map = {
|
|
165
|
+
pygit2.GIT_DELTA_ADDED: "added",
|
|
166
|
+
pygit2.GIT_DELTA_DELETED: "deleted",
|
|
167
|
+
pygit2.GIT_DELTA_MODIFIED: "modified",
|
|
168
|
+
pygit2.GIT_DELTA_RENAMED: "renamed",
|
|
169
|
+
}
|
|
170
|
+
status = status_map.get(delta.status, "modified")
|
|
171
|
+
entries.append(DiffEntry(
|
|
172
|
+
path=delta.new_file.path,
|
|
173
|
+
status=status,
|
|
174
|
+
old_path=delta.old_file.path if status == "renamed" else None,
|
|
175
|
+
additions=patch.line_stats[1],
|
|
176
|
+
deletions=patch.line_stats[2],
|
|
177
|
+
))
|
|
178
|
+
return entries
|
|
179
|
+
|
|
180
|
+
def read_file(
|
|
181
|
+
self,
|
|
182
|
+
repo_id: str,
|
|
183
|
+
ref: str,
|
|
184
|
+
file_path: str,
|
|
185
|
+
) -> bytes:
|
|
186
|
+
"""Read a file's content at a specific ref."""
|
|
187
|
+
import pygit2
|
|
188
|
+
|
|
189
|
+
path = self._repo_path(repo_id)
|
|
190
|
+
repo = pygit2.Repository(str(path))
|
|
191
|
+
commit = self._resolve_ref(repo, ref)
|
|
192
|
+
tree = commit.tree
|
|
193
|
+
|
|
194
|
+
# Walk the tree path
|
|
195
|
+
entry = tree[file_path]
|
|
196
|
+
blob = repo.get(entry.id)
|
|
197
|
+
if blob is None or not isinstance(blob, pygit2.Blob):
|
|
198
|
+
raise FileNotFoundError(f"File not found: {file_path} at {ref}")
|
|
199
|
+
return blob.data
|
|
200
|
+
|
|
201
|
+
def get_tree(
|
|
202
|
+
self,
|
|
203
|
+
repo_id: str,
|
|
204
|
+
ref: str,
|
|
205
|
+
path: str = "",
|
|
206
|
+
) -> list[TreeEntry]:
|
|
207
|
+
"""List entries in a directory at a specific ref."""
|
|
208
|
+
import pygit2
|
|
209
|
+
|
|
210
|
+
repo_path = self._repo_path(repo_id)
|
|
211
|
+
repo = pygit2.Repository(str(repo_path))
|
|
212
|
+
commit = self._resolve_ref(repo, ref)
|
|
213
|
+
tree = commit.tree
|
|
214
|
+
|
|
215
|
+
if path:
|
|
216
|
+
entry = tree[path]
|
|
217
|
+
subtree = repo.get(entry.id)
|
|
218
|
+
if not isinstance(subtree, pygit2.Tree):
|
|
219
|
+
raise ValueError(f"Path is not a directory: {path}")
|
|
220
|
+
tree = subtree
|
|
221
|
+
|
|
222
|
+
entries = []
|
|
223
|
+
for entry in tree:
|
|
224
|
+
obj = repo.get(entry.id)
|
|
225
|
+
entry_type = "blob" if isinstance(obj, pygit2.Blob) else "tree"
|
|
226
|
+
size = len(obj.data) if isinstance(obj, pygit2.Blob) else 0
|
|
227
|
+
entry_path = f"{path}/{entry.name}" if path else entry.name
|
|
228
|
+
entries.append(TreeEntry(
|
|
229
|
+
name=entry.name,
|
|
230
|
+
path=entry_path,
|
|
231
|
+
type=entry_type,
|
|
232
|
+
size=size,
|
|
233
|
+
oid=str(entry.id),
|
|
234
|
+
))
|
|
235
|
+
return entries
|
|
236
|
+
|
|
237
|
+
def get_commit_log(
|
|
238
|
+
self,
|
|
239
|
+
repo_id: str,
|
|
240
|
+
ref: str,
|
|
241
|
+
path: str | None = None,
|
|
242
|
+
limit: int = 50,
|
|
243
|
+
offset: int = 0,
|
|
244
|
+
) -> list[CommitInfo]:
|
|
245
|
+
"""Walk commit history from a ref, optionally filtered by path."""
|
|
246
|
+
import pygit2
|
|
247
|
+
|
|
248
|
+
repo_path = self._repo_path(repo_id)
|
|
249
|
+
if not repo_path.exists():
|
|
250
|
+
raise FileNotFoundError(f"Repository {repo_id} not found")
|
|
251
|
+
|
|
252
|
+
repo = pygit2.Repository(str(repo_path))
|
|
253
|
+
commit = self._resolve_ref(repo, ref)
|
|
254
|
+
|
|
255
|
+
commits = []
|
|
256
|
+
seen = 0
|
|
257
|
+
for c in repo.walk(commit.id, pygit2.GIT_SORT_TIME):
|
|
258
|
+
if path:
|
|
259
|
+
# Filter: only include commits that touch the specified path
|
|
260
|
+
if not self._commit_touches_path(repo, c, path):
|
|
261
|
+
continue
|
|
262
|
+
if seen < offset:
|
|
263
|
+
seen += 1
|
|
264
|
+
continue
|
|
265
|
+
commits.append(CommitInfo(
|
|
266
|
+
oid=str(c.id),
|
|
267
|
+
message=c.message.strip(),
|
|
268
|
+
author_name=c.author.name,
|
|
269
|
+
author_email=c.author.email,
|
|
270
|
+
timestamp=c.commit_time,
|
|
271
|
+
parent_oids=[str(p) for p in c.parent_ids],
|
|
272
|
+
))
|
|
273
|
+
if len(commits) >= limit:
|
|
274
|
+
break
|
|
275
|
+
seen += 1
|
|
276
|
+
|
|
277
|
+
return commits
|
|
278
|
+
|
|
279
|
+
def _commit_touches_path(self, repo, commit, path: str) -> bool:
|
|
280
|
+
"""Check if a commit modified the given path compared to its first parent."""
|
|
281
|
+
try:
|
|
282
|
+
entry = commit.tree[path]
|
|
283
|
+
except KeyError:
|
|
284
|
+
# Path doesn't exist in this commit
|
|
285
|
+
if commit.parents:
|
|
286
|
+
try:
|
|
287
|
+
commit.parents[0].tree[path]
|
|
288
|
+
return True # Deleted in this commit
|
|
289
|
+
except KeyError:
|
|
290
|
+
return False
|
|
291
|
+
return False
|
|
292
|
+
|
|
293
|
+
if not commit.parents:
|
|
294
|
+
return True # Initial commit
|
|
295
|
+
|
|
296
|
+
try:
|
|
297
|
+
parent_entry = commit.parents[0].tree[path]
|
|
298
|
+
return entry.id != parent_entry.id
|
|
299
|
+
except KeyError:
|
|
300
|
+
return True # Added in this commit
|
|
301
|
+
|
|
302
|
+
def get_commit_detail(
|
|
303
|
+
self,
|
|
304
|
+
repo_id: str,
|
|
305
|
+
commit_sha: str,
|
|
306
|
+
) -> tuple[CommitInfo, list[PatchEntry]]:
|
|
307
|
+
"""Get full commit info + diff against parent."""
|
|
308
|
+
import pygit2
|
|
309
|
+
|
|
310
|
+
path = self._repo_path(repo_id)
|
|
311
|
+
if not path.exists():
|
|
312
|
+
raise FileNotFoundError(f"Repository {repo_id} not found")
|
|
313
|
+
|
|
314
|
+
repo = pygit2.Repository(str(path))
|
|
315
|
+
commit = self._resolve_ref(repo, commit_sha)
|
|
316
|
+
|
|
317
|
+
info = CommitInfo(
|
|
318
|
+
oid=str(commit.id),
|
|
319
|
+
message=commit.message.strip(),
|
|
320
|
+
author_name=commit.author.name,
|
|
321
|
+
author_email=commit.author.email,
|
|
322
|
+
timestamp=commit.commit_time,
|
|
323
|
+
parent_oids=[str(p) for p in commit.parent_ids],
|
|
324
|
+
)
|
|
325
|
+
|
|
326
|
+
# Diff against first parent (or empty tree for root commit)
|
|
327
|
+
if commit.parents:
|
|
328
|
+
diff = repo.diff(commit.parents[0], commit)
|
|
329
|
+
else:
|
|
330
|
+
diff = commit.tree.diff_to_tree()
|
|
331
|
+
|
|
332
|
+
patches = self._diff_to_patches(diff)
|
|
333
|
+
return info, patches
|
|
334
|
+
|
|
335
|
+
def get_blame(
|
|
336
|
+
self,
|
|
337
|
+
repo_id: str,
|
|
338
|
+
ref: str,
|
|
339
|
+
file_path: str,
|
|
340
|
+
) -> list[BlameHunk]:
|
|
341
|
+
"""Get line-level blame for a file at a ref."""
|
|
342
|
+
import pygit2
|
|
343
|
+
|
|
344
|
+
path = self._repo_path(repo_id)
|
|
345
|
+
if not path.exists():
|
|
346
|
+
raise FileNotFoundError(f"Repository {repo_id} not found")
|
|
347
|
+
|
|
348
|
+
repo = pygit2.Repository(str(path))
|
|
349
|
+
commit = self._resolve_ref(repo, ref)
|
|
350
|
+
|
|
351
|
+
blame = repo.blame(file_path, newest_commit=commit.id)
|
|
352
|
+
hunks = []
|
|
353
|
+
for hunk in blame:
|
|
354
|
+
hunks.append(BlameHunk(
|
|
355
|
+
commit_oid=str(hunk.final_commit_id),
|
|
356
|
+
author_name=hunk.final_committer.name if hunk.final_committer else "",
|
|
357
|
+
author_email=hunk.final_committer.email if hunk.final_committer else "",
|
|
358
|
+
timestamp=hunk.final_committer.time if hunk.final_committer else 0,
|
|
359
|
+
start_line=hunk.final_start_line_number,
|
|
360
|
+
end_line=hunk.final_start_line_number + hunk.lines_in_hunk - 1,
|
|
361
|
+
))
|
|
362
|
+
|
|
363
|
+
return hunks
|
|
364
|
+
|
|
365
|
+
def get_patch(
|
|
366
|
+
self,
|
|
367
|
+
repo_id: str,
|
|
368
|
+
from_ref: str,
|
|
369
|
+
to_ref: str,
|
|
370
|
+
path: str | None = None,
|
|
371
|
+
) -> list[PatchEntry]:
|
|
372
|
+
"""Get diff with full patch content between two refs."""
|
|
373
|
+
import pygit2
|
|
374
|
+
|
|
375
|
+
repo_path = self._repo_path(repo_id)
|
|
376
|
+
if not repo_path.exists():
|
|
377
|
+
raise FileNotFoundError(f"Repository {repo_id} not found")
|
|
378
|
+
|
|
379
|
+
repo = pygit2.Repository(str(repo_path))
|
|
380
|
+
from_commit = self._resolve_ref(repo, from_ref)
|
|
381
|
+
to_commit = self._resolve_ref(repo, to_ref)
|
|
382
|
+
|
|
383
|
+
diff = repo.diff(from_commit, to_commit)
|
|
384
|
+
patches = self._diff_to_patches(diff)
|
|
385
|
+
|
|
386
|
+
if path:
|
|
387
|
+
patches = [p for p in patches if p.path == path or p.old_path == path]
|
|
388
|
+
|
|
389
|
+
return patches
|
|
390
|
+
|
|
391
|
+
def _diff_to_patches(self, diff) -> list[PatchEntry]:
|
|
392
|
+
"""Convert a pygit2 Diff to structured PatchEntry list."""
|
|
393
|
+
import pygit2
|
|
394
|
+
|
|
395
|
+
status_map = {
|
|
396
|
+
pygit2.GIT_DELTA_ADDED: "added",
|
|
397
|
+
pygit2.GIT_DELTA_DELETED: "deleted",
|
|
398
|
+
pygit2.GIT_DELTA_MODIFIED: "modified",
|
|
399
|
+
pygit2.GIT_DELTA_RENAMED: "renamed",
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
patches = []
|
|
403
|
+
for patch in diff:
|
|
404
|
+
delta = patch.delta
|
|
405
|
+
status = status_map.get(delta.status, "modified")
|
|
406
|
+
|
|
407
|
+
hunks = []
|
|
408
|
+
for hunk in patch.hunks:
|
|
409
|
+
lines = []
|
|
410
|
+
for line in hunk.lines:
|
|
411
|
+
lines.append(DiffLine(
|
|
412
|
+
origin=line.origin,
|
|
413
|
+
content=line.content.rstrip("\n"),
|
|
414
|
+
old_lineno=line.old_lineno if line.old_lineno >= 0 else None,
|
|
415
|
+
new_lineno=line.new_lineno if line.new_lineno >= 0 else None,
|
|
416
|
+
))
|
|
417
|
+
hunks.append(DiffHunk(
|
|
418
|
+
old_start=hunk.old_start,
|
|
419
|
+
old_lines=hunk.old_lines,
|
|
420
|
+
new_start=hunk.new_start,
|
|
421
|
+
new_lines=hunk.new_lines,
|
|
422
|
+
header=hunk.header.strip(),
|
|
423
|
+
lines=lines,
|
|
424
|
+
))
|
|
425
|
+
|
|
426
|
+
patches.append(PatchEntry(
|
|
427
|
+
path=delta.new_file.path,
|
|
428
|
+
status=status,
|
|
429
|
+
old_path=delta.old_file.path if status == "renamed" else None,
|
|
430
|
+
additions=patch.line_stats[1],
|
|
431
|
+
deletions=patch.line_stats[2],
|
|
432
|
+
hunks=hunks,
|
|
433
|
+
))
|
|
434
|
+
|
|
435
|
+
return patches
|
|
436
|
+
|
|
437
|
+
def get_disk_usage(self, repo_id: str) -> int:
|
|
438
|
+
"""Get total disk usage of a repo clone in bytes."""
|
|
439
|
+
path = self._repo_path(repo_id)
|
|
440
|
+
if not path.exists():
|
|
441
|
+
return 0
|
|
442
|
+
total = 0
|
|
443
|
+
for dirpath, dirnames, filenames in os.walk(path):
|
|
444
|
+
for f in filenames:
|
|
445
|
+
fp = os.path.join(dirpath, f)
|
|
446
|
+
if os.path.isfile(fp):
|
|
447
|
+
total += os.path.getsize(fp)
|
|
448
|
+
return total
|
|
449
|
+
|
|
450
|
+
def delete(self, repo_id: str) -> None:
|
|
451
|
+
"""Delete a repo's bare clone from disk.
|
|
452
|
+
|
|
453
|
+
Skips deletion for repos with path overrides (local directory imports)
|
|
454
|
+
to avoid nuking the user's project directory.
|
|
455
|
+
"""
|
|
456
|
+
if repo_id in self._path_overrides:
|
|
457
|
+
logger.info("Skipping delete for local repo %s (path override)", repo_id)
|
|
458
|
+
self._path_overrides.pop(repo_id, None)
|
|
459
|
+
return
|
|
460
|
+
|
|
461
|
+
import shutil
|
|
462
|
+
|
|
463
|
+
path = self._repo_path(repo_id)
|
|
464
|
+
if path.exists():
|
|
465
|
+
shutil.rmtree(path)
|
|
466
|
+
logger.info("Deleted clone for %s", repo_id)
|
|
467
|
+
|
|
468
|
+
def _resolve_ref(self, repo, ref: str):
|
|
469
|
+
"""Resolve a ref string to a pygit2 Commit object."""
|
|
470
|
+
import pygit2
|
|
471
|
+
|
|
472
|
+
# Try as branch name
|
|
473
|
+
for prefix in ("refs/heads/", "refs/remotes/origin/", ""):
|
|
474
|
+
full_ref = f"{prefix}{ref}" if prefix else ref
|
|
475
|
+
try:
|
|
476
|
+
reference = repo.references[full_ref]
|
|
477
|
+
obj = repo.get(reference.target)
|
|
478
|
+
if isinstance(obj, pygit2.Commit):
|
|
479
|
+
return obj
|
|
480
|
+
except (KeyError, ValueError):
|
|
481
|
+
pass
|
|
482
|
+
|
|
483
|
+
# Try as commit hash
|
|
484
|
+
try:
|
|
485
|
+
obj = repo.revparse_single(ref)
|
|
486
|
+
if isinstance(obj, pygit2.Commit):
|
|
487
|
+
return obj
|
|
488
|
+
if isinstance(obj, pygit2.Tag):
|
|
489
|
+
return obj.get_object()
|
|
490
|
+
except (KeyError, ValueError):
|
|
491
|
+
pass
|
|
492
|
+
|
|
493
|
+
raise ValueError(f"Cannot resolve ref: {ref}")
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""Data models for git operations."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass(slots=True)
|
|
9
|
+
class BranchInfo:
|
|
10
|
+
"""Information about a git branch."""
|
|
11
|
+
|
|
12
|
+
name: str
|
|
13
|
+
commit: str
|
|
14
|
+
is_default: bool = False
|
|
15
|
+
ahead: int = 0
|
|
16
|
+
behind: int = 0
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(slots=True)
|
|
20
|
+
class DiffEntry:
|
|
21
|
+
"""A single file change in a diff."""
|
|
22
|
+
|
|
23
|
+
path: str
|
|
24
|
+
status: str # added|modified|deleted|renamed
|
|
25
|
+
old_path: str | None = None # For renames
|
|
26
|
+
additions: int = 0
|
|
27
|
+
deletions: int = 0
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(slots=True)
|
|
31
|
+
class TreeEntry:
|
|
32
|
+
"""An entry in a git tree (file or directory)."""
|
|
33
|
+
|
|
34
|
+
name: str
|
|
35
|
+
path: str
|
|
36
|
+
type: str # blob|tree
|
|
37
|
+
size: int = 0
|
|
38
|
+
oid: str = ""
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass(slots=True)
|
|
42
|
+
class CommitInfo:
|
|
43
|
+
"""Information about a git commit."""
|
|
44
|
+
|
|
45
|
+
oid: str
|
|
46
|
+
message: str
|
|
47
|
+
author_name: str
|
|
48
|
+
author_email: str
|
|
49
|
+
timestamp: int
|
|
50
|
+
parent_oids: list[str] = field(default_factory=list)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass(slots=True)
|
|
54
|
+
class BlameHunk:
|
|
55
|
+
"""A contiguous block of lines attributed to one commit."""
|
|
56
|
+
|
|
57
|
+
commit_oid: str
|
|
58
|
+
author_name: str
|
|
59
|
+
author_email: str
|
|
60
|
+
timestamp: int
|
|
61
|
+
start_line: int
|
|
62
|
+
end_line: int
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@dataclass(slots=True)
|
|
66
|
+
class DiffLine:
|
|
67
|
+
"""A single line in a diff hunk."""
|
|
68
|
+
|
|
69
|
+
origin: str # '+'/'-'/' '
|
|
70
|
+
content: str
|
|
71
|
+
old_lineno: int | None = None
|
|
72
|
+
new_lineno: int | None = None
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@dataclass(slots=True)
|
|
76
|
+
class DiffHunk:
|
|
77
|
+
"""A hunk within a patch."""
|
|
78
|
+
|
|
79
|
+
old_start: int
|
|
80
|
+
old_lines: int
|
|
81
|
+
new_start: int
|
|
82
|
+
new_lines: int
|
|
83
|
+
header: str
|
|
84
|
+
lines: list[DiffLine] = field(default_factory=list)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@dataclass(slots=True)
|
|
88
|
+
class PatchEntry:
|
|
89
|
+
"""A file change with full diff hunks."""
|
|
90
|
+
|
|
91
|
+
path: str
|
|
92
|
+
status: str
|
|
93
|
+
old_path: str | None = None
|
|
94
|
+
additions: int = 0
|
|
95
|
+
deletions: int = 0
|
|
96
|
+
hunks: list[DiffHunk] = field(default_factory=list)
|