codeframe-ai 0.9.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.
Files changed (197) hide show
  1. codeframe/__init__.py +11 -0
  2. codeframe/__main__.py +20 -0
  3. codeframe/adapters/__init__.py +5 -0
  4. codeframe/adapters/e2b/__init__.py +13 -0
  5. codeframe/adapters/e2b/adapter.py +342 -0
  6. codeframe/adapters/e2b/budget.py +71 -0
  7. codeframe/adapters/e2b/credential_scanner.py +134 -0
  8. codeframe/adapters/llm/__init__.py +92 -0
  9. codeframe/adapters/llm/anthropic.py +414 -0
  10. codeframe/adapters/llm/base.py +444 -0
  11. codeframe/adapters/llm/mock.py +281 -0
  12. codeframe/adapters/llm/openai.py +483 -0
  13. codeframe/agents/__init__.py +8 -0
  14. codeframe/agents/dependency_resolver.py +714 -0
  15. codeframe/auth/__init__.py +16 -0
  16. codeframe/auth/api_key_router.py +238 -0
  17. codeframe/auth/api_keys.py +156 -0
  18. codeframe/auth/dependencies.py +358 -0
  19. codeframe/auth/manager.py +178 -0
  20. codeframe/auth/models.py +30 -0
  21. codeframe/auth/router.py +93 -0
  22. codeframe/auth/schemas.py +15 -0
  23. codeframe/auth/scopes.py +53 -0
  24. codeframe/cli/__init__.py +12 -0
  25. codeframe/cli/__main__.py +20 -0
  26. codeframe/cli/api_client.py +275 -0
  27. codeframe/cli/app.py +5688 -0
  28. codeframe/cli/auth.py +122 -0
  29. codeframe/cli/auth_commands.py +958 -0
  30. codeframe/cli/commands/__init__.py +5 -0
  31. codeframe/cli/config_commands.py +79 -0
  32. codeframe/cli/dashboard_commands.py +67 -0
  33. codeframe/cli/engines_commands.py +205 -0
  34. codeframe/cli/env_commands.py +409 -0
  35. codeframe/cli/helpers.py +56 -0
  36. codeframe/cli/hooks_commands.py +208 -0
  37. codeframe/cli/import_commands.py +129 -0
  38. codeframe/cli/pr_commands.py +549 -0
  39. codeframe/cli/proof_commands.py +415 -0
  40. codeframe/cli/stats_commands.py +311 -0
  41. codeframe/cli/telemetry_runtime.py +153 -0
  42. codeframe/cli/validators.py +123 -0
  43. codeframe/config/rate_limits.py +165 -0
  44. codeframe/core/__init__.py +15 -0
  45. codeframe/core/adapters/__init__.py +43 -0
  46. codeframe/core/adapters/agent_adapter.py +114 -0
  47. codeframe/core/adapters/builtin.py +326 -0
  48. codeframe/core/adapters/claude_code.py +62 -0
  49. codeframe/core/adapters/codex.py +393 -0
  50. codeframe/core/adapters/git_utils.py +40 -0
  51. codeframe/core/adapters/kilocode.py +126 -0
  52. codeframe/core/adapters/opencode.py +48 -0
  53. codeframe/core/adapters/streaming_chat.py +483 -0
  54. codeframe/core/adapters/subprocess_adapter.py +213 -0
  55. codeframe/core/adapters/verification_wrapper.py +269 -0
  56. codeframe/core/agent.py +2183 -0
  57. codeframe/core/agents_config.py +569 -0
  58. codeframe/core/api_key_service.py +211 -0
  59. codeframe/core/artifacts.py +428 -0
  60. codeframe/core/blocker_detection.py +218 -0
  61. codeframe/core/blockers.py +433 -0
  62. codeframe/core/checkpoints.py +481 -0
  63. codeframe/core/conductor.py +2255 -0
  64. codeframe/core/config.py +827 -0
  65. codeframe/core/config_watcher.py +268 -0
  66. codeframe/core/context.py +542 -0
  67. codeframe/core/context_packager.py +234 -0
  68. codeframe/core/credentials.py +735 -0
  69. codeframe/core/dependency_analyzer.py +229 -0
  70. codeframe/core/dependency_graph.py +290 -0
  71. codeframe/core/diagnostic_agent.py +712 -0
  72. codeframe/core/diagnostics.py +616 -0
  73. codeframe/core/editor.py +556 -0
  74. codeframe/core/engine_registry.py +256 -0
  75. codeframe/core/engine_stats.py +231 -0
  76. codeframe/core/environment.py +697 -0
  77. codeframe/core/events.py +375 -0
  78. codeframe/core/executor.py +1005 -0
  79. codeframe/core/fix_tracker.py +480 -0
  80. codeframe/core/gates.py +1322 -0
  81. codeframe/core/git.py +477 -0
  82. codeframe/core/github_connect_service.py +178 -0
  83. codeframe/core/github_integration_config.py +118 -0
  84. codeframe/core/github_issues_service.py +449 -0
  85. codeframe/core/hooks.py +184 -0
  86. codeframe/core/importers/__init__.py +1 -0
  87. codeframe/core/importers/ralph.py +540 -0
  88. codeframe/core/installer.py +650 -0
  89. codeframe/core/models.py +1026 -0
  90. codeframe/core/notifications_config.py +183 -0
  91. codeframe/core/planner.py +437 -0
  92. codeframe/core/prd.py +670 -0
  93. codeframe/core/prd_discovery.py +1118 -0
  94. codeframe/core/prd_stress_test.py +499 -0
  95. codeframe/core/progress.py +126 -0
  96. codeframe/core/proof/__init__.py +34 -0
  97. codeframe/core/proof/capture.py +79 -0
  98. codeframe/core/proof/evidence.py +56 -0
  99. codeframe/core/proof/ledger.py +574 -0
  100. codeframe/core/proof/models.py +162 -0
  101. codeframe/core/proof/obligations.py +103 -0
  102. codeframe/core/proof/runner.py +233 -0
  103. codeframe/core/proof/scope.py +81 -0
  104. codeframe/core/proof/stubs.py +156 -0
  105. codeframe/core/quick_fixes.py +558 -0
  106. codeframe/core/react_agent.py +1650 -0
  107. codeframe/core/reconciliation.py +183 -0
  108. codeframe/core/replay.py +788 -0
  109. codeframe/core/review.py +285 -0
  110. codeframe/core/runtime.py +1134 -0
  111. codeframe/core/sandbox/__init__.py +27 -0
  112. codeframe/core/sandbox/context.py +98 -0
  113. codeframe/core/sandbox/worktree.py +20 -0
  114. codeframe/core/schedule.py +396 -0
  115. codeframe/core/stall_detector.py +71 -0
  116. codeframe/core/stall_monitor.py +134 -0
  117. codeframe/core/state_machine.py +121 -0
  118. codeframe/core/streaming.py +502 -0
  119. codeframe/core/task_tree.py +400 -0
  120. codeframe/core/tasks.py +1022 -0
  121. codeframe/core/telemetry.py +232 -0
  122. codeframe/core/templates.py +221 -0
  123. codeframe/core/tools.py +942 -0
  124. codeframe/core/workspace.py +887 -0
  125. codeframe/core/worktrees.py +276 -0
  126. codeframe/git/__init__.py +5 -0
  127. codeframe/git/github_integration.py +505 -0
  128. codeframe/lib/__init__.py +0 -0
  129. codeframe/lib/audit_logger.py +248 -0
  130. codeframe/lib/metrics_tracker.py +800 -0
  131. codeframe/lib/quality/__init__.py +7 -0
  132. codeframe/lib/quality/complexity_analyzer.py +316 -0
  133. codeframe/lib/quality/owasp_patterns.py +284 -0
  134. codeframe/lib/quality/security_scanner.py +250 -0
  135. codeframe/lib/rate_limiter.py +312 -0
  136. codeframe/notifications/__init__.py +0 -0
  137. codeframe/notifications/webhook.py +380 -0
  138. codeframe/planning/__init__.py +30 -0
  139. codeframe/planning/issue_generator.py +219 -0
  140. codeframe/planning/prd_template_functions.py +137 -0
  141. codeframe/planning/prd_templates.py +975 -0
  142. codeframe/planning/task_scheduler.py +511 -0
  143. codeframe/planning/task_templates.py +533 -0
  144. codeframe/platform_store/__init__.py +5 -0
  145. codeframe/platform_store/database.py +277 -0
  146. codeframe/platform_store/repositories/__init__.py +24 -0
  147. codeframe/platform_store/repositories/api_key_repository.py +245 -0
  148. codeframe/platform_store/repositories/audit_repository.py +67 -0
  149. codeframe/platform_store/repositories/base.py +295 -0
  150. codeframe/platform_store/repositories/interactive_sessions.py +165 -0
  151. codeframe/platform_store/repositories/token_repository.py +598 -0
  152. codeframe/platform_store/repositories/workspace_registry_repository.py +175 -0
  153. codeframe/platform_store/schema_manager.py +321 -0
  154. codeframe/templates/AGENTS.md.default +94 -0
  155. codeframe/tui/__init__.py +5 -0
  156. codeframe/tui/app.py +256 -0
  157. codeframe/tui/data_service.py +103 -0
  158. codeframe/ui/__init__.py +0 -0
  159. codeframe/ui/dependencies.py +103 -0
  160. codeframe/ui/models.py +999 -0
  161. codeframe/ui/response_models.py +201 -0
  162. codeframe/ui/routers/__init__.py +5 -0
  163. codeframe/ui/routers/_helpers.py +29 -0
  164. codeframe/ui/routers/batches_v2.py +315 -0
  165. codeframe/ui/routers/blockers_v2.py +320 -0
  166. codeframe/ui/routers/checkpoints_v2.py +310 -0
  167. codeframe/ui/routers/costs_v2.py +322 -0
  168. codeframe/ui/routers/diagnose_v2.py +225 -0
  169. codeframe/ui/routers/discovery_v2.py +417 -0
  170. codeframe/ui/routers/environment_v2.py +284 -0
  171. codeframe/ui/routers/events_v2.py +75 -0
  172. codeframe/ui/routers/gates_v2.py +166 -0
  173. codeframe/ui/routers/git_v2.py +284 -0
  174. codeframe/ui/routers/github_integrations_v2.py +532 -0
  175. codeframe/ui/routers/interactive_sessions_v2.py +238 -0
  176. codeframe/ui/routers/pr_v2.py +709 -0
  177. codeframe/ui/routers/prd_v2.py +695 -0
  178. codeframe/ui/routers/proof_v2.py +755 -0
  179. codeframe/ui/routers/review_v2.py +360 -0
  180. codeframe/ui/routers/schedule_v2.py +214 -0
  181. codeframe/ui/routers/session_chat_ws.py +354 -0
  182. codeframe/ui/routers/settings_v2.py +562 -0
  183. codeframe/ui/routers/streaming_v2.py +155 -0
  184. codeframe/ui/routers/tasks_v2.py +1098 -0
  185. codeframe/ui/routers/templates_v2.py +232 -0
  186. codeframe/ui/routers/terminal_ws.py +267 -0
  187. codeframe/ui/routers/workspace_v2.py +527 -0
  188. codeframe/ui/server.py +568 -0
  189. codeframe/ui/shared.py +241 -0
  190. codeframe/workspace/__init__.py +5 -0
  191. codeframe/workspace/manager.py +249 -0
  192. codeframe_ai-0.9.0.dist-info/METADATA +517 -0
  193. codeframe_ai-0.9.0.dist-info/RECORD +197 -0
  194. codeframe_ai-0.9.0.dist-info/WHEEL +5 -0
  195. codeframe_ai-0.9.0.dist-info/entry_points.txt +3 -0
  196. codeframe_ai-0.9.0.dist-info/licenses/LICENSE +661 -0
  197. codeframe_ai-0.9.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,211 @@
1
+ """Core business logic for API key management.
2
+
3
+ This service is used by both CLI commands and REST API endpoints,
4
+ ensuring consistent behavior across interfaces.
5
+ """
6
+
7
+ import logging
8
+ from dataclasses import dataclass
9
+ from datetime import datetime, timezone
10
+ from typing import List, Optional
11
+
12
+ from codeframe.auth.api_keys import (
13
+ generate_api_key,
14
+ validate_scopes,
15
+ SCOPE_READ,
16
+ SCOPE_WRITE,
17
+ )
18
+ from codeframe.platform_store.database import Database
19
+
20
+ logger = logging.getLogger(__name__)
21
+
22
+
23
+ @dataclass
24
+ class ApiKeyInfo:
25
+ """API key information (without sensitive data)."""
26
+
27
+ id: str
28
+ name: str
29
+ prefix: str
30
+ scopes: List[str]
31
+ created_at: str
32
+ last_used_at: Optional[str]
33
+ expires_at: Optional[str]
34
+ is_active: bool
35
+
36
+
37
+ @dataclass
38
+ class CreatedApiKey:
39
+ """Result of creating an API key (includes full key shown once)."""
40
+
41
+ key: str # Full key - shown only once
42
+ id: str
43
+ prefix: str
44
+ created_at: str
45
+
46
+
47
+ class ApiKeyService:
48
+ """Service for API key operations.
49
+
50
+ Encapsulates business logic for creating, listing, and revoking API keys.
51
+ Used by both CLI and REST API to ensure consistent behavior.
52
+ """
53
+
54
+ def __init__(self, db: Database):
55
+ """Initialize with database connection.
56
+
57
+ Args:
58
+ db: Initialized Database instance
59
+ """
60
+ self.db = db
61
+
62
+ def create_api_key(
63
+ self,
64
+ user_id: int,
65
+ name: str,
66
+ scopes: Optional[List[str]] = None,
67
+ expires_at: Optional[datetime] = None,
68
+ ) -> CreatedApiKey:
69
+ """Create a new API key for a user.
70
+
71
+ Args:
72
+ user_id: Owner user ID
73
+ name: Human-readable name for the key
74
+ scopes: Permission scopes (defaults to read, write)
75
+ expires_at: Optional expiration timestamp
76
+
77
+ Returns:
78
+ CreatedApiKey with the full key (shown only once)
79
+
80
+ Raises:
81
+ ValueError: If scopes are invalid
82
+ """
83
+ if scopes is None:
84
+ scopes = [SCOPE_READ, SCOPE_WRITE]
85
+
86
+ if not validate_scopes(scopes):
87
+ raise ValueError(f"Invalid scopes: {scopes}. Valid scopes: read, write, admin")
88
+
89
+ # Generate the key
90
+ full_key, key_hash, prefix = generate_api_key()
91
+
92
+ # Store in database
93
+ key_id = self.db.api_keys.create(
94
+ user_id=user_id,
95
+ name=name,
96
+ key_hash=key_hash,
97
+ prefix=prefix,
98
+ scopes=scopes,
99
+ expires_at=expires_at,
100
+ )
101
+
102
+ logger.info(f"API key created for user {user_id}: {prefix}...")
103
+
104
+ return CreatedApiKey(
105
+ key=full_key,
106
+ id=key_id,
107
+ prefix=prefix,
108
+ created_at=datetime.now(timezone.utc).isoformat(),
109
+ )
110
+
111
+ def list_api_keys(self, user_id: int) -> List[ApiKeyInfo]:
112
+ """List all API keys for a user.
113
+
114
+ Args:
115
+ user_id: The user's ID
116
+
117
+ Returns:
118
+ List of API key info (without hashes)
119
+ """
120
+ keys = self.db.api_keys.list_user_keys(user_id=user_id)
121
+
122
+ return [
123
+ ApiKeyInfo(
124
+ id=k["id"],
125
+ name=k["name"],
126
+ prefix=k["prefix"],
127
+ scopes=k["scopes"],
128
+ created_at=k["created_at"],
129
+ last_used_at=k.get("last_used_at"),
130
+ expires_at=k.get("expires_at"),
131
+ is_active=k["is_active"],
132
+ )
133
+ for k in keys
134
+ ]
135
+
136
+ def revoke_api_key(self, key_id: str, user_id: int) -> bool:
137
+ """Revoke an API key.
138
+
139
+ Args:
140
+ key_id: The API key ID to revoke
141
+ user_id: The user attempting to revoke (must be owner)
142
+
143
+ Returns:
144
+ True if revoked, False if not found or not owned
145
+ """
146
+ success = self.db.api_keys.revoke(key_id, user_id=user_id)
147
+
148
+ if success:
149
+ logger.info(f"API key revoked by user {user_id}: {key_id}")
150
+
151
+ return success
152
+
153
+ def rotate_api_key(self, key_id: str, user_id: int) -> Optional[CreatedApiKey]:
154
+ """Rotate an API key (create new, then revoke old).
155
+
156
+ Creates the new key first to ensure the user always has a working key.
157
+ If creation fails, the old key remains active.
158
+
159
+ Args:
160
+ key_id: The API key ID to rotate
161
+ user_id: The user attempting to rotate (must be owner)
162
+
163
+ Returns:
164
+ New CreatedApiKey, or None if old key not found/owned
165
+
166
+ Raises:
167
+ ValueError: If new key creation fails (old key remains active)
168
+ """
169
+ # Get the old key's configuration
170
+ old_key = self.db.api_keys.get_by_id(key_id)
171
+ if old_key is None or old_key["user_id"] != user_id:
172
+ return None
173
+
174
+ # Create new key first - if this fails, old key stays active
175
+ new_key = self.create_api_key(
176
+ user_id=user_id,
177
+ name=old_key["name"],
178
+ scopes=old_key["scopes"],
179
+ expires_at=None, # New key starts fresh
180
+ )
181
+
182
+ # Now revoke the old key (brief window where both are active)
183
+ self.db.api_keys.revoke(key_id, user_id=user_id)
184
+
185
+ logger.info(f"API key rotated by user {user_id}: {key_id} -> {new_key.id}")
186
+
187
+ return new_key
188
+
189
+ def get_api_key(self, key_id: str) -> Optional[ApiKeyInfo]:
190
+ """Get a single API key's info.
191
+
192
+ Args:
193
+ key_id: The API key ID
194
+
195
+ Returns:
196
+ ApiKeyInfo or None if not found
197
+ """
198
+ key = self.db.api_keys.get_by_id(key_id)
199
+ if key is None:
200
+ return None
201
+
202
+ return ApiKeyInfo(
203
+ id=key["id"],
204
+ name=key["name"],
205
+ prefix=key["prefix"],
206
+ scopes=key["scopes"],
207
+ created_at=key["created_at"],
208
+ last_used_at=key.get("last_used_at"),
209
+ expires_at=key.get("expires_at"),
210
+ is_active=key["is_active"],
211
+ )
@@ -0,0 +1,428 @@
1
+ """Artifact management for CodeFRAME v2.
2
+
3
+ Handles patch export and git commit operations.
4
+
5
+ This module is headless - no FastAPI or HTTP dependencies.
6
+ """
7
+
8
+ import subprocess
9
+ import shutil
10
+ from dataclasses import dataclass
11
+ from datetime import datetime, timezone
12
+ from pathlib import Path
13
+ from typing import Optional
14
+
15
+ from codeframe.core.workspace import Workspace
16
+ from codeframe.core import events
17
+
18
+
19
+ def _utc_now() -> datetime:
20
+ """Get current UTC time as timezone-aware datetime."""
21
+ return datetime.now(timezone.utc)
22
+
23
+
24
+ @dataclass
25
+ class PatchInfo:
26
+ """Information about an exported patch.
27
+
28
+ Attributes:
29
+ path: Path to the patch file
30
+ size_bytes: Size of the patch file
31
+ files_changed: Number of files in the patch
32
+ insertions: Lines added
33
+ deletions: Lines removed
34
+ created_at: When the patch was created
35
+ """
36
+
37
+ path: Path
38
+ size_bytes: int
39
+ files_changed: int
40
+ insertions: int
41
+ deletions: int
42
+ created_at: datetime
43
+
44
+
45
+ @dataclass
46
+ class CommitInfo:
47
+ """Information about a created commit.
48
+
49
+ Attributes:
50
+ hash: Commit hash (short)
51
+ full_hash: Full commit hash
52
+ message: Commit message
53
+ files_changed: Number of files changed
54
+ insertions: Lines added
55
+ deletions: Lines removed
56
+ created_at: When the commit was created
57
+ """
58
+
59
+ hash: str
60
+ full_hash: str
61
+ message: str
62
+ files_changed: int
63
+ insertions: int
64
+ deletions: int
65
+ created_at: datetime
66
+
67
+
68
+ def export_patch(
69
+ workspace: Workspace,
70
+ out_path: Optional[Path] = None,
71
+ staged_only: bool = False,
72
+ ) -> PatchInfo:
73
+ """Export changes as a patch file.
74
+
75
+ Args:
76
+ workspace: Target workspace
77
+ out_path: Output path (auto-generated if None)
78
+ staged_only: Only include staged changes
79
+
80
+ Returns:
81
+ PatchInfo with patch details
82
+
83
+ Raises:
84
+ ValueError: If no changes to export or git not available
85
+ """
86
+ repo_path = workspace.repo_path
87
+
88
+ # Check git is available
89
+ if not shutil.which("git"):
90
+ raise ValueError("git not found in PATH")
91
+
92
+ # Check we're in a git repo
93
+ result = subprocess.run(
94
+ ["git", "rev-parse", "--git-dir"],
95
+ cwd=repo_path,
96
+ capture_output=True,
97
+ text=True,
98
+ )
99
+ if result.returncode != 0:
100
+ raise ValueError(f"Not a git repository: {repo_path}")
101
+
102
+ # Generate patch content
103
+ # Track which diff was actually used for stats calculation
104
+ actual_staged_used = staged_only
105
+
106
+ if staged_only:
107
+ diff_cmd = ["git", "diff", "--cached"]
108
+ else:
109
+ diff_cmd = ["git", "diff", "HEAD"]
110
+
111
+ result = subprocess.run(
112
+ diff_cmd,
113
+ cwd=repo_path,
114
+ capture_output=True,
115
+ text=True,
116
+ )
117
+
118
+ patch_content = result.stdout
119
+
120
+ # Track if we fell back to plain unstaged diff (git diff without HEAD)
121
+ fell_back_to_plain_unstaged = False
122
+
123
+ if not patch_content.strip():
124
+ # Try just unstaged changes (git diff without HEAD)
125
+ result = subprocess.run(
126
+ ["git", "diff"],
127
+ cwd=repo_path,
128
+ capture_output=True,
129
+ text=True,
130
+ )
131
+ patch_content = result.stdout
132
+ # We fell back to unstaged diff, so update the tracking flag
133
+ actual_staged_used = False
134
+ fell_back_to_plain_unstaged = True
135
+
136
+ if not patch_content.strip():
137
+ raise ValueError("No changes to export")
138
+
139
+ # Generate output path if not provided
140
+ if out_path is None:
141
+ timestamp = _utc_now().strftime("%Y%m%d-%H%M%S")
142
+ out_path = repo_path / f".codeframe/patches/patch-{timestamp}.patch"
143
+
144
+ # Ensure parent directory exists
145
+ out_path.parent.mkdir(parents=True, exist_ok=True)
146
+
147
+ # Write patch
148
+ out_path.write_text(patch_content)
149
+
150
+ # Get stats - when fell back to plain unstaged, parse from content directly
151
+ # because _get_diff_stats runs "git diff HEAD --stat" which may return zeros
152
+ if fell_back_to_plain_unstaged:
153
+ stats = _parse_patch_content_stats(patch_content)
154
+ else:
155
+ stats = _get_diff_stats(repo_path, actual_staged_used)
156
+
157
+ patch_info = PatchInfo(
158
+ path=out_path,
159
+ size_bytes=out_path.stat().st_size,
160
+ files_changed=stats["files"],
161
+ insertions=stats["insertions"],
162
+ deletions=stats["deletions"],
163
+ created_at=_utc_now(),
164
+ )
165
+
166
+ # Emit event
167
+ events.emit_for_workspace(
168
+ workspace,
169
+ events.EventType.PATCH_EXPORTED,
170
+ {
171
+ "path": str(out_path),
172
+ "size": patch_info.size_bytes,
173
+ "files": patch_info.files_changed,
174
+ },
175
+ print_event=True,
176
+ )
177
+
178
+ return patch_info
179
+
180
+
181
+ def create_commit(
182
+ workspace: Workspace,
183
+ message: str,
184
+ add_all: bool = False,
185
+ ) -> CommitInfo:
186
+ """Create a git commit.
187
+
188
+ Args:
189
+ workspace: Target workspace
190
+ message: Commit message
191
+ add_all: Stage all changes before committing
192
+
193
+ Returns:
194
+ CommitInfo with commit details
195
+
196
+ Raises:
197
+ ValueError: If commit fails or nothing to commit
198
+ """
199
+ repo_path = workspace.repo_path
200
+
201
+ # Check git is available
202
+ if not shutil.which("git"):
203
+ raise ValueError("git not found in PATH")
204
+
205
+ # Optionally stage all changes
206
+ if add_all:
207
+ result = subprocess.run(
208
+ ["git", "add", "-A"],
209
+ cwd=repo_path,
210
+ capture_output=True,
211
+ text=True,
212
+ )
213
+ if result.returncode != 0:
214
+ raise ValueError(f"git add failed: {result.stderr}")
215
+
216
+ # Check there are staged changes
217
+ result = subprocess.run(
218
+ ["git", "diff", "--cached", "--stat"],
219
+ cwd=repo_path,
220
+ capture_output=True,
221
+ text=True,
222
+ )
223
+ if not result.stdout.strip():
224
+ raise ValueError("No staged changes to commit")
225
+
226
+ # Get stats before commit
227
+ stats = _get_diff_stats(repo_path, staged_only=True)
228
+
229
+ # Create commit
230
+ result = subprocess.run(
231
+ ["git", "commit", "-m", message],
232
+ cwd=repo_path,
233
+ capture_output=True,
234
+ text=True,
235
+ )
236
+
237
+ if result.returncode != 0:
238
+ raise ValueError(f"git commit failed: {result.stderr}")
239
+
240
+ # Get commit hash
241
+ result = subprocess.run(
242
+ ["git", "rev-parse", "HEAD"],
243
+ cwd=repo_path,
244
+ capture_output=True,
245
+ text=True,
246
+ )
247
+ full_hash = result.stdout.strip()
248
+ short_hash = full_hash[:7]
249
+
250
+ commit_info = CommitInfo(
251
+ hash=short_hash,
252
+ full_hash=full_hash,
253
+ message=message,
254
+ files_changed=stats["files"],
255
+ insertions=stats["insertions"],
256
+ deletions=stats["deletions"],
257
+ created_at=_utc_now(),
258
+ )
259
+
260
+ # Emit event
261
+ events.emit_for_workspace(
262
+ workspace,
263
+ events.EventType.COMMIT_CREATED,
264
+ {
265
+ "hash": short_hash,
266
+ "message": message[:100],
267
+ "files": commit_info.files_changed,
268
+ },
269
+ print_event=True,
270
+ )
271
+
272
+ return commit_info
273
+
274
+
275
+ def get_status(repo_path: Path) -> dict:
276
+ """Get git status summary.
277
+
278
+ Args:
279
+ repo_path: Repository path
280
+
281
+ Returns:
282
+ Dict with status info
283
+ """
284
+ result = subprocess.run(
285
+ ["git", "status", "--porcelain"],
286
+ cwd=repo_path,
287
+ capture_output=True,
288
+ text=True,
289
+ )
290
+
291
+ lines = [line for line in result.stdout.strip().split("\n") if line]
292
+
293
+ staged = []
294
+ unstaged = []
295
+ untracked = []
296
+
297
+ for line in lines:
298
+ if len(line) < 3:
299
+ continue
300
+ index_status = line[0]
301
+ worktree_status = line[1]
302
+ filename = line[3:]
303
+
304
+ if index_status == "?":
305
+ untracked.append(filename)
306
+ elif index_status != " ":
307
+ staged.append(filename)
308
+ if worktree_status != " " and worktree_status != "?":
309
+ unstaged.append(filename)
310
+
311
+ return {
312
+ "staged": staged,
313
+ "unstaged": unstaged,
314
+ "untracked": untracked,
315
+ "clean": len(lines) == 0,
316
+ }
317
+
318
+
319
+ def list_patches(workspace: Workspace) -> list[PatchInfo]:
320
+ """List exported patches.
321
+
322
+ Args:
323
+ workspace: Target workspace
324
+
325
+ Returns:
326
+ List of PatchInfo, newest first
327
+ """
328
+ patches_dir = workspace.state_dir / "patches"
329
+ if not patches_dir.exists():
330
+ return []
331
+
332
+ patches = []
333
+ for patch_file in sorted(patches_dir.glob("*.patch"), reverse=True):
334
+ stats = _parse_patch_stats(patch_file)
335
+ patches.append(
336
+ PatchInfo(
337
+ path=patch_file,
338
+ size_bytes=patch_file.stat().st_size,
339
+ files_changed=stats.get("files", 0),
340
+ insertions=stats.get("insertions", 0),
341
+ deletions=stats.get("deletions", 0),
342
+ created_at=datetime.fromtimestamp(
343
+ patch_file.stat().st_mtime, tz=timezone.utc
344
+ ),
345
+ )
346
+ )
347
+
348
+ return patches
349
+
350
+
351
+ def _get_diff_stats(repo_path: Path, staged_only: bool = False) -> dict:
352
+ """Get diff statistics."""
353
+ if staged_only:
354
+ cmd = ["git", "diff", "--cached", "--stat"]
355
+ else:
356
+ cmd = ["git", "diff", "HEAD", "--stat"]
357
+
358
+ result = subprocess.run(
359
+ cmd,
360
+ cwd=repo_path,
361
+ capture_output=True,
362
+ text=True,
363
+ )
364
+
365
+ # Parse the last line which has summary
366
+ lines = result.stdout.strip().split("\n")
367
+ if not lines or not lines[-1]:
368
+ return {"files": 0, "insertions": 0, "deletions": 0}
369
+
370
+ summary = lines[-1]
371
+
372
+ # Parse "X files changed, Y insertions(+), Z deletions(-)"
373
+ files = 0
374
+ insertions = 0
375
+ deletions = 0
376
+
377
+ import re
378
+
379
+ files_match = re.search(r"(\d+) files? changed", summary)
380
+ if files_match:
381
+ files = int(files_match.group(1))
382
+
383
+ ins_match = re.search(r"(\d+) insertions?\(\+\)", summary)
384
+ if ins_match:
385
+ insertions = int(ins_match.group(1))
386
+
387
+ del_match = re.search(r"(\d+) deletions?\(-\)", summary)
388
+ if del_match:
389
+ deletions = int(del_match.group(1))
390
+
391
+ return {"files": files, "insertions": insertions, "deletions": deletions}
392
+
393
+
394
+ def _parse_patch_content_stats(content: str) -> dict:
395
+ """Parse stats from patch content string.
396
+
397
+ Args:
398
+ content: Raw patch content (git diff output)
399
+
400
+ Returns:
401
+ Dict with files, insertions, deletions counts
402
+ """
403
+ files = set()
404
+ insertions = 0
405
+ deletions = 0
406
+
407
+ for line in content.split("\n"):
408
+ if line.startswith("diff --git"):
409
+ # Extract filename
410
+ parts = line.split()
411
+ if len(parts) >= 4:
412
+ files.add(parts[3])
413
+ elif line.startswith("+") and not line.startswith("+++"):
414
+ insertions += 1
415
+ elif line.startswith("-") and not line.startswith("---"):
416
+ deletions += 1
417
+
418
+ return {
419
+ "files": len(files),
420
+ "insertions": insertions,
421
+ "deletions": deletions,
422
+ }
423
+
424
+
425
+ def _parse_patch_stats(patch_file: Path) -> dict:
426
+ """Parse stats from a patch file."""
427
+ content = patch_file.read_text()
428
+ return _parse_patch_content_stats(content)