emdash-core 0.1.33__py3-none-any.whl → 0.1.60__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.
- emdash_core/agent/agents.py +93 -23
- emdash_core/agent/background.py +481 -0
- emdash_core/agent/hooks.py +419 -0
- emdash_core/agent/inprocess_subagent.py +114 -10
- emdash_core/agent/mcp/config.py +78 -2
- emdash_core/agent/prompts/main_agent.py +88 -1
- emdash_core/agent/prompts/plan_mode.py +65 -44
- emdash_core/agent/prompts/subagents.py +96 -8
- emdash_core/agent/prompts/workflow.py +215 -50
- emdash_core/agent/providers/models.py +1 -1
- emdash_core/agent/providers/openai_provider.py +10 -0
- emdash_core/agent/research/researcher.py +154 -45
- emdash_core/agent/runner/agent_runner.py +157 -19
- emdash_core/agent/runner/context.py +28 -9
- emdash_core/agent/runner/sdk_runner.py +29 -2
- emdash_core/agent/skills.py +81 -1
- emdash_core/agent/toolkit.py +87 -11
- emdash_core/agent/toolkits/__init__.py +117 -18
- emdash_core/agent/toolkits/base.py +87 -2
- emdash_core/agent/toolkits/explore.py +18 -0
- emdash_core/agent/toolkits/plan.py +18 -0
- emdash_core/agent/tools/__init__.py +2 -0
- emdash_core/agent/tools/coding.py +344 -52
- emdash_core/agent/tools/lsp.py +361 -0
- emdash_core/agent/tools/skill.py +21 -1
- emdash_core/agent/tools/task.py +27 -23
- emdash_core/agent/tools/task_output.py +262 -32
- emdash_core/agent/verifier/__init__.py +11 -0
- emdash_core/agent/verifier/manager.py +295 -0
- emdash_core/agent/verifier/models.py +97 -0
- emdash_core/{swarm/worktree_manager.py → agent/worktree.py} +19 -1
- emdash_core/api/agent.py +451 -5
- emdash_core/api/research.py +3 -3
- emdash_core/api/router.py +0 -4
- emdash_core/context/longevity.py +197 -0
- emdash_core/context/providers/explored_areas.py +83 -39
- emdash_core/context/reranker.py +35 -144
- emdash_core/context/simple_reranker.py +500 -0
- emdash_core/context/tool_relevance.py +84 -0
- emdash_core/core/config.py +8 -0
- emdash_core/graph/__init__.py +8 -1
- emdash_core/graph/connection.py +24 -3
- emdash_core/graph/writer.py +7 -1
- emdash_core/ingestion/repository.py +17 -198
- emdash_core/models/agent.py +14 -0
- emdash_core/server.py +1 -6
- emdash_core/sse/stream.py +16 -1
- emdash_core/utils/__init__.py +0 -2
- emdash_core/utils/git.py +103 -0
- emdash_core/utils/image.py +147 -160
- {emdash_core-0.1.33.dist-info → emdash_core-0.1.60.dist-info}/METADATA +7 -5
- {emdash_core-0.1.33.dist-info → emdash_core-0.1.60.dist-info}/RECORD +54 -58
- emdash_core/api/swarm.py +0 -223
- emdash_core/db/__init__.py +0 -67
- emdash_core/db/auth.py +0 -134
- emdash_core/db/models.py +0 -91
- emdash_core/db/provider.py +0 -222
- emdash_core/db/providers/__init__.py +0 -5
- emdash_core/db/providers/supabase.py +0 -452
- emdash_core/swarm/__init__.py +0 -17
- emdash_core/swarm/merge_agent.py +0 -383
- emdash_core/swarm/session_manager.py +0 -274
- emdash_core/swarm/swarm_runner.py +0 -226
- emdash_core/swarm/task_definition.py +0 -137
- emdash_core/swarm/worker_spawner.py +0 -319
- {emdash_core-0.1.33.dist-info → emdash_core-0.1.60.dist-info}/WHEEL +0 -0
- {emdash_core-0.1.33.dist-info → emdash_core-0.1.60.dist-info}/entry_points.txt +0 -0
|
@@ -1,15 +1,9 @@
|
|
|
1
|
-
"""Repository management
|
|
1
|
+
"""Repository management for local Git repositories."""
|
|
2
2
|
|
|
3
|
-
import hashlib
|
|
4
|
-
import os
|
|
5
|
-
import shutil
|
|
6
|
-
from datetime import datetime
|
|
7
3
|
from pathlib import Path
|
|
8
4
|
from typing import Optional
|
|
9
|
-
from urllib.parse import urlparse
|
|
10
5
|
|
|
11
|
-
import
|
|
12
|
-
from git import Repo, GitCommandError
|
|
6
|
+
from git import Repo
|
|
13
7
|
|
|
14
8
|
from ..core.exceptions import RepositoryError
|
|
15
9
|
from ..core.models import RepositoryEntity
|
|
@@ -17,44 +11,37 @@ from ..utils.logger import log
|
|
|
17
11
|
|
|
18
12
|
|
|
19
13
|
class RepositoryManager:
|
|
20
|
-
"""Manages Git repository operations
|
|
14
|
+
"""Manages local Git repository operations."""
|
|
21
15
|
|
|
22
|
-
def __init__(self
|
|
23
|
-
"""Initialize repository manager.
|
|
24
|
-
|
|
25
|
-
Args:
|
|
26
|
-
cache_dir: Directory to cache cloned repositories.
|
|
27
|
-
Defaults to ~/.emdash/repos
|
|
28
|
-
"""
|
|
29
|
-
if cache_dir is None:
|
|
30
|
-
cache_dir = Path.home() / ".emdash" / "repos"
|
|
31
|
-
|
|
32
|
-
self.cache_dir = cache_dir
|
|
33
|
-
self.cache_dir.mkdir(parents=True, exist_ok=True)
|
|
16
|
+
def __init__(self):
|
|
17
|
+
"""Initialize repository manager."""
|
|
18
|
+
pass
|
|
34
19
|
|
|
35
20
|
def get_or_clone(
|
|
36
21
|
self,
|
|
37
22
|
repo_path: str,
|
|
38
23
|
skip_commit_count: bool = False
|
|
39
24
|
) -> tuple[Repo, RepositoryEntity]:
|
|
40
|
-
"""Get a repository
|
|
25
|
+
"""Get a local repository.
|
|
41
26
|
|
|
42
27
|
Args:
|
|
43
|
-
repo_path:
|
|
44
|
-
skip_commit_count: Whether to skip counting commits
|
|
28
|
+
repo_path: Local path to repository
|
|
29
|
+
skip_commit_count: Whether to skip counting commits (unused, kept for API compatibility)
|
|
45
30
|
|
|
46
31
|
Returns:
|
|
47
32
|
Tuple of (git.Repo, RepositoryEntity)
|
|
48
33
|
|
|
49
34
|
Raises:
|
|
50
|
-
RepositoryError: If repository cannot be accessed
|
|
35
|
+
RepositoryError: If repository cannot be accessed or path doesn't exist
|
|
51
36
|
"""
|
|
52
|
-
#
|
|
53
|
-
if Path(repo_path).exists():
|
|
54
|
-
|
|
37
|
+
# Only support local paths
|
|
38
|
+
if not Path(repo_path).exists():
|
|
39
|
+
raise RepositoryError(
|
|
40
|
+
f"Repository path does not exist: {repo_path}. "
|
|
41
|
+
"Remote repository URLs are not supported - please provide a local path."
|
|
42
|
+
)
|
|
55
43
|
|
|
56
|
-
|
|
57
|
-
return self._clone_or_fetch(repo_path, skip_commit_count)
|
|
44
|
+
return self._open_local_repo(repo_path)
|
|
58
45
|
|
|
59
46
|
def _open_local_repo(self, path: str) -> tuple[Repo, RepositoryEntity]:
|
|
60
47
|
"""Open a local repository.
|
|
@@ -88,164 +75,6 @@ class RepositoryManager:
|
|
|
88
75
|
except Exception as e:
|
|
89
76
|
raise RepositoryError(f"Failed to open local repository {path}: {e}")
|
|
90
77
|
|
|
91
|
-
def _clone_or_fetch(
|
|
92
|
-
self,
|
|
93
|
-
url: str,
|
|
94
|
-
skip_commit_count: bool
|
|
95
|
-
) -> tuple[Repo, RepositoryEntity]:
|
|
96
|
-
"""Clone a repository or fetch updates if already cloned.
|
|
97
|
-
|
|
98
|
-
Args:
|
|
99
|
-
url: Repository URL
|
|
100
|
-
skip_commit_count: Whether to skip counting commits
|
|
101
|
-
|
|
102
|
-
Returns:
|
|
103
|
-
Tuple of (git.Repo, RepositoryEntity)
|
|
104
|
-
"""
|
|
105
|
-
# Generate cache path from URL
|
|
106
|
-
cache_path = self._get_cache_path(url)
|
|
107
|
-
|
|
108
|
-
if cache_path.exists():
|
|
109
|
-
log.info(f"Repository already cached at {cache_path}")
|
|
110
|
-
return self._fetch_updates(cache_path, url, skip_commit_count)
|
|
111
|
-
else:
|
|
112
|
-
log.info(f"Cloning repository: {url}")
|
|
113
|
-
return self._clone_repo(url, cache_path, skip_commit_count)
|
|
114
|
-
|
|
115
|
-
def _clone_repo(
|
|
116
|
-
self,
|
|
117
|
-
url: str,
|
|
118
|
-
cache_path: Path,
|
|
119
|
-
skip_commit_count: bool
|
|
120
|
-
) -> tuple[Repo, RepositoryEntity]:
|
|
121
|
-
"""Clone a repository.
|
|
122
|
-
|
|
123
|
-
Args:
|
|
124
|
-
url: Repository URL
|
|
125
|
-
cache_path: Path to clone into
|
|
126
|
-
skip_commit_count: Whether to skip counting commits
|
|
127
|
-
|
|
128
|
-
Returns:
|
|
129
|
-
Tuple of (git.Repo, RepositoryEntity)
|
|
130
|
-
"""
|
|
131
|
-
try:
|
|
132
|
-
repo = Repo.clone_from(url, cache_path, depth=None)
|
|
133
|
-
log.info(f"Successfully cloned {url}")
|
|
134
|
-
|
|
135
|
-
entity = self._create_repository_entity(
|
|
136
|
-
repo,
|
|
137
|
-
url,
|
|
138
|
-
skip_commit_count=skip_commit_count
|
|
139
|
-
)
|
|
140
|
-
return repo, entity
|
|
141
|
-
|
|
142
|
-
except GitCommandError as e:
|
|
143
|
-
raise RepositoryError(f"Failed to clone repository {url}: {e}")
|
|
144
|
-
except Exception as e:
|
|
145
|
-
raise RepositoryError(f"Unexpected error cloning {url}: {e}")
|
|
146
|
-
|
|
147
|
-
def _fetch_updates(
|
|
148
|
-
self,
|
|
149
|
-
cache_path: Path,
|
|
150
|
-
url: str,
|
|
151
|
-
skip_commit_count: bool
|
|
152
|
-
) -> tuple[Repo, RepositoryEntity]:
|
|
153
|
-
"""Fetch updates for an existing repository.
|
|
154
|
-
|
|
155
|
-
Args:
|
|
156
|
-
cache_path: Path to cached repository
|
|
157
|
-
url: Repository URL
|
|
158
|
-
skip_commit_count: Whether to skip counting commits
|
|
159
|
-
|
|
160
|
-
Returns:
|
|
161
|
-
Tuple of (git.Repo, RepositoryEntity)
|
|
162
|
-
"""
|
|
163
|
-
try:
|
|
164
|
-
repo = Repo(cache_path)
|
|
165
|
-
|
|
166
|
-
log.info("Fetching updates from remote...")
|
|
167
|
-
repo.remotes.origin.fetch()
|
|
168
|
-
|
|
169
|
-
# Pull latest changes
|
|
170
|
-
repo.remotes.origin.pull()
|
|
171
|
-
|
|
172
|
-
log.info("Repository updated successfully")
|
|
173
|
-
|
|
174
|
-
entity = self._create_repository_entity(
|
|
175
|
-
repo,
|
|
176
|
-
url,
|
|
177
|
-
skip_commit_count=skip_commit_count
|
|
178
|
-
)
|
|
179
|
-
return repo, entity
|
|
180
|
-
|
|
181
|
-
except GitCommandError as e:
|
|
182
|
-
raise RepositoryError(f"Failed to fetch updates for {url}: {e}")
|
|
183
|
-
except Exception as e:
|
|
184
|
-
raise RepositoryError(f"Unexpected error fetching updates: {e}")
|
|
185
|
-
|
|
186
|
-
def _create_repository_entity(
|
|
187
|
-
self,
|
|
188
|
-
repo: Repo,
|
|
189
|
-
url: str,
|
|
190
|
-
skip_commit_count: bool = False
|
|
191
|
-
) -> RepositoryEntity:
|
|
192
|
-
"""Create a RepositoryEntity from a git.Repo.
|
|
193
|
-
|
|
194
|
-
Args:
|
|
195
|
-
repo: Git repository
|
|
196
|
-
url: Repository URL
|
|
197
|
-
skip_commit_count: Whether to skip counting commits
|
|
198
|
-
|
|
199
|
-
Returns:
|
|
200
|
-
RepositoryEntity
|
|
201
|
-
"""
|
|
202
|
-
# Parse URL to extract owner and name
|
|
203
|
-
parsed = urlparse(url)
|
|
204
|
-
path_parts = parsed.path.strip("/").split("/")
|
|
205
|
-
|
|
206
|
-
if len(path_parts) >= 2:
|
|
207
|
-
owner = path_parts[-2]
|
|
208
|
-
repo_name = path_parts[-1].replace(".git", "")
|
|
209
|
-
else:
|
|
210
|
-
owner = None
|
|
211
|
-
repo_name = path_parts[-1].replace(".git", "") if path_parts else "unknown"
|
|
212
|
-
|
|
213
|
-
commit_count = 0
|
|
214
|
-
if not skip_commit_count:
|
|
215
|
-
try:
|
|
216
|
-
commit_count = sum(1 for _ in repo.iter_commits())
|
|
217
|
-
except Exception:
|
|
218
|
-
commit_count = 0
|
|
219
|
-
|
|
220
|
-
return RepositoryEntity(
|
|
221
|
-
url=url,
|
|
222
|
-
name=repo_name,
|
|
223
|
-
owner=owner,
|
|
224
|
-
default_branch=repo.active_branch.name if repo.active_branch else "main",
|
|
225
|
-
last_ingested=None,
|
|
226
|
-
ingestion_status="pending",
|
|
227
|
-
commit_count=commit_count,
|
|
228
|
-
)
|
|
229
|
-
|
|
230
|
-
def _get_cache_path(self, url: str) -> Path:
|
|
231
|
-
"""Get the cache path for a repository URL.
|
|
232
|
-
|
|
233
|
-
Args:
|
|
234
|
-
url: Repository URL
|
|
235
|
-
|
|
236
|
-
Returns:
|
|
237
|
-
Path to cache directory
|
|
238
|
-
"""
|
|
239
|
-
# Create a unique directory name from URL
|
|
240
|
-
url_hash = hashlib.md5(url.encode()).hexdigest()[:12]
|
|
241
|
-
|
|
242
|
-
# Extract repo name from URL
|
|
243
|
-
parsed = urlparse(url)
|
|
244
|
-
path_parts = parsed.path.strip("/").split("/")
|
|
245
|
-
repo_name = path_parts[-1].replace(".git", "")
|
|
246
|
-
|
|
247
|
-
return self.cache_dir / f"{repo_name}_{url_hash}"
|
|
248
|
-
|
|
249
78
|
def _get_origin_url(self, repo: Repo) -> Optional[str]:
|
|
250
79
|
"""Get the origin URL of a repository.
|
|
251
80
|
|
|
@@ -334,13 +163,3 @@ class RepositoryManager:
|
|
|
334
163
|
This is a convenience wrapper around get_source_files() for backward compatibility.
|
|
335
164
|
"""
|
|
336
165
|
return self.get_source_files(repo, ['.py'], ignore_patterns)
|
|
337
|
-
|
|
338
|
-
def clear_cache(self):
|
|
339
|
-
"""Clear all cached repositories."""
|
|
340
|
-
log.warning("Clearing repository cache...")
|
|
341
|
-
|
|
342
|
-
if self.cache_dir.exists():
|
|
343
|
-
shutil.rmtree(self.cache_dir)
|
|
344
|
-
self.cache_dir.mkdir(parents=True, exist_ok=True)
|
|
345
|
-
|
|
346
|
-
log.info("Cache cleared successfully")
|
emdash_core/models/agent.py
CHANGED
|
@@ -1,11 +1,17 @@
|
|
|
1
1
|
"""Pydantic models for agent API."""
|
|
2
2
|
|
|
3
|
+
import os
|
|
3
4
|
from enum import Enum
|
|
4
5
|
from typing import Optional
|
|
5
6
|
|
|
6
7
|
from pydantic import BaseModel, Field
|
|
7
8
|
|
|
8
9
|
|
|
10
|
+
def _default_use_worktree() -> bool:
|
|
11
|
+
"""Get default worktree setting from environment."""
|
|
12
|
+
return os.environ.get("EMDASH_USE_WORKTREE", "false").lower() in ("true", "1", "yes")
|
|
13
|
+
|
|
14
|
+
|
|
9
15
|
class AgentMode(str, Enum):
|
|
10
16
|
"""Agent operation modes."""
|
|
11
17
|
|
|
@@ -33,6 +39,10 @@ class AgentChatOptions(BaseModel):
|
|
|
33
39
|
default=0.6,
|
|
34
40
|
description="Context window threshold for summarization (0-1)"
|
|
35
41
|
)
|
|
42
|
+
use_worktree: bool = Field(
|
|
43
|
+
default_factory=_default_use_worktree,
|
|
44
|
+
description="Use git worktree for isolated changes (EMDASH_USE_WORKTREE env)"
|
|
45
|
+
)
|
|
36
46
|
|
|
37
47
|
|
|
38
48
|
class AgentChatRequest(BaseModel):
|
|
@@ -51,6 +61,10 @@ class AgentChatRequest(BaseModel):
|
|
|
51
61
|
default_factory=list,
|
|
52
62
|
description="Images for vision-capable models"
|
|
53
63
|
)
|
|
64
|
+
history: list[dict] = Field(
|
|
65
|
+
default_factory=list,
|
|
66
|
+
description="Pre-loaded conversation history from saved session"
|
|
67
|
+
)
|
|
54
68
|
options: AgentChatOptions = Field(
|
|
55
69
|
default_factory=AgentChatOptions,
|
|
56
70
|
description="Agent options"
|
emdash_core/server.py
CHANGED
|
@@ -89,18 +89,13 @@ async def lifespan(app: FastAPI):
|
|
|
89
89
|
if config.repo_root:
|
|
90
90
|
print(f"Repository root: {config.repo_root}")
|
|
91
91
|
|
|
92
|
-
#
|
|
93
|
-
port_file = Path.home() / ".emdash" / "server.port"
|
|
94
|
-
port_file.parent.mkdir(parents=True, exist_ok=True)
|
|
95
|
-
port_file.write_text(str(config.port))
|
|
92
|
+
# Note: Port file management is handled by ServerManager (per-repo files)
|
|
96
93
|
|
|
97
94
|
yield
|
|
98
95
|
|
|
99
96
|
# Shutdown
|
|
100
97
|
print("EmDash Core shutting down...")
|
|
101
98
|
_shutdown_executors()
|
|
102
|
-
if port_file.exists():
|
|
103
|
-
port_file.unlink()
|
|
104
99
|
|
|
105
100
|
|
|
106
101
|
def create_app(
|
emdash_core/sse/stream.py
CHANGED
|
@@ -8,6 +8,8 @@ from typing import Any, AsyncIterator
|
|
|
8
8
|
|
|
9
9
|
from pydantic import BaseModel
|
|
10
10
|
|
|
11
|
+
from ..utils.logger import log
|
|
12
|
+
|
|
11
13
|
|
|
12
14
|
class EventType(str, Enum):
|
|
13
15
|
"""Types of events emitted by agents (matches emdash.agent.events.EventType)."""
|
|
@@ -27,10 +29,12 @@ class EventType(str, Enum):
|
|
|
27
29
|
# Output
|
|
28
30
|
RESPONSE = "response"
|
|
29
31
|
PARTIAL_RESPONSE = "partial_response"
|
|
32
|
+
ASSISTANT_TEXT = "assistant_text"
|
|
30
33
|
|
|
31
34
|
# Interaction
|
|
32
35
|
CLARIFICATION = "clarification"
|
|
33
36
|
CLARIFICATION_RESPONSE = "clarification_response"
|
|
37
|
+
PLAN_MODE_REQUESTED = "plan_mode_requested"
|
|
34
38
|
PLAN_SUBMITTED = "plan_submitted"
|
|
35
39
|
|
|
36
40
|
# Errors
|
|
@@ -100,9 +104,20 @@ class SSEHandler:
|
|
|
100
104
|
if self._closed:
|
|
101
105
|
return
|
|
102
106
|
|
|
107
|
+
# Convert event type, with error handling for unknown types
|
|
108
|
+
try:
|
|
109
|
+
event_type = EventType(event.type.value)
|
|
110
|
+
except ValueError:
|
|
111
|
+
log.warning(
|
|
112
|
+
f"Unknown SSE event type: {event.type.value} - event dropped. "
|
|
113
|
+
"This may indicate EventType enum in sse/stream.py is out of sync "
|
|
114
|
+
"with agent/events.py"
|
|
115
|
+
)
|
|
116
|
+
return
|
|
117
|
+
|
|
103
118
|
# Convert to SSEEvent
|
|
104
119
|
sse_event = SSEEvent(
|
|
105
|
-
type=
|
|
120
|
+
type=event_type,
|
|
106
121
|
data=event.data,
|
|
107
122
|
timestamp=event.timestamp,
|
|
108
123
|
agent_name=event.agent_name or self._agent_name,
|
emdash_core/utils/__init__.py
CHANGED
|
@@ -7,7 +7,6 @@ from .image import (
|
|
|
7
7
|
read_clipboard_image,
|
|
8
8
|
encode_image_to_base64,
|
|
9
9
|
encode_image_for_llm,
|
|
10
|
-
resize_image_if_needed,
|
|
11
10
|
get_image_info,
|
|
12
11
|
estimate_image_tokens,
|
|
13
12
|
read_and_prepare_image,
|
|
@@ -31,7 +30,6 @@ __all__ = [
|
|
|
31
30
|
"read_clipboard_image",
|
|
32
31
|
"encode_image_to_base64",
|
|
33
32
|
"encode_image_for_llm",
|
|
34
|
-
"resize_image_if_needed",
|
|
35
33
|
"get_image_info",
|
|
36
34
|
"estimate_image_tokens",
|
|
37
35
|
"read_and_prepare_image",
|
emdash_core/utils/git.py
CHANGED
|
@@ -82,3 +82,106 @@ def get_normalized_remote_url(repo_root: Path) -> Optional[str]:
|
|
|
82
82
|
if remote_url:
|
|
83
83
|
return normalize_repo_url(remote_url)
|
|
84
84
|
return None
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def get_current_branch(repo_root: Path) -> Optional[str]:
|
|
88
|
+
"""Get the current git branch name.
|
|
89
|
+
|
|
90
|
+
Args:
|
|
91
|
+
repo_root: Path to the git repository root
|
|
92
|
+
|
|
93
|
+
Returns:
|
|
94
|
+
The current branch name or None if not found/not in a git repo
|
|
95
|
+
"""
|
|
96
|
+
try:
|
|
97
|
+
result = subprocess.run(
|
|
98
|
+
["git", "rev-parse", "--abbrev-ref", "HEAD"],
|
|
99
|
+
cwd=repo_root,
|
|
100
|
+
capture_output=True,
|
|
101
|
+
text=True,
|
|
102
|
+
timeout=5,
|
|
103
|
+
)
|
|
104
|
+
if result.returncode == 0:
|
|
105
|
+
return result.stdout.strip()
|
|
106
|
+
except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
|
|
107
|
+
pass
|
|
108
|
+
return None
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def get_git_status_summary(repo_root: Path) -> Optional[str]:
|
|
112
|
+
"""Get a brief summary of git status.
|
|
113
|
+
|
|
114
|
+
Args:
|
|
115
|
+
repo_root: Path to the git repository root
|
|
116
|
+
|
|
117
|
+
Returns:
|
|
118
|
+
Brief status summary (e.g., "3 modified, 2 untracked") or None
|
|
119
|
+
"""
|
|
120
|
+
try:
|
|
121
|
+
result = subprocess.run(
|
|
122
|
+
["git", "status", "--porcelain"],
|
|
123
|
+
cwd=repo_root,
|
|
124
|
+
capture_output=True,
|
|
125
|
+
text=True,
|
|
126
|
+
timeout=5,
|
|
127
|
+
)
|
|
128
|
+
if result.returncode != 0:
|
|
129
|
+
return None
|
|
130
|
+
|
|
131
|
+
lines = result.stdout.strip().split("\n") if result.stdout.strip() else []
|
|
132
|
+
if not lines:
|
|
133
|
+
return "clean"
|
|
134
|
+
|
|
135
|
+
# Count by status type
|
|
136
|
+
modified = 0
|
|
137
|
+
untracked = 0
|
|
138
|
+
staged = 0
|
|
139
|
+
deleted = 0
|
|
140
|
+
|
|
141
|
+
for line in lines:
|
|
142
|
+
if not line:
|
|
143
|
+
continue
|
|
144
|
+
status = line[:2]
|
|
145
|
+
if status == "??":
|
|
146
|
+
untracked += 1
|
|
147
|
+
elif status[0] in ("M", "A", "D", "R", "C"):
|
|
148
|
+
staged += 1
|
|
149
|
+
elif status[1] == "M":
|
|
150
|
+
modified += 1
|
|
151
|
+
elif status[1] == "D":
|
|
152
|
+
deleted += 1
|
|
153
|
+
|
|
154
|
+
parts = []
|
|
155
|
+
if staged:
|
|
156
|
+
parts.append(f"{staged} staged")
|
|
157
|
+
if modified:
|
|
158
|
+
parts.append(f"{modified} modified")
|
|
159
|
+
if deleted:
|
|
160
|
+
parts.append(f"{deleted} deleted")
|
|
161
|
+
if untracked:
|
|
162
|
+
parts.append(f"{untracked} untracked")
|
|
163
|
+
|
|
164
|
+
return ", ".join(parts) if parts else "clean"
|
|
165
|
+
|
|
166
|
+
except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
|
|
167
|
+
pass
|
|
168
|
+
return None
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def get_repo_name(repo_root: Path) -> Optional[str]:
|
|
172
|
+
"""Get the repository name from the remote URL or directory name.
|
|
173
|
+
|
|
174
|
+
Args:
|
|
175
|
+
repo_root: Path to the git repository root
|
|
176
|
+
|
|
177
|
+
Returns:
|
|
178
|
+
Repository name (e.g., "user/repo") or None
|
|
179
|
+
"""
|
|
180
|
+
remote_url = get_normalized_remote_url(repo_root)
|
|
181
|
+
if remote_url:
|
|
182
|
+
# Extract user/repo from https://github.com/user/repo
|
|
183
|
+
parts = remote_url.rstrip("/").split("/")
|
|
184
|
+
if len(parts) >= 2:
|
|
185
|
+
return f"{parts[-2]}/{parts[-1]}"
|
|
186
|
+
# Fallback to directory name
|
|
187
|
+
return repo_root.name
|