zjcode 0.0.1__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.
- deepagents_code/__init__.py +42 -0
- deepagents_code/__main__.py +6 -0
- deepagents_code/_ask_user_types.py +90 -0
- deepagents_code/_cli_context.py +96 -0
- deepagents_code/_constants.py +41 -0
- deepagents_code/_debug.py +148 -0
- deepagents_code/_debug_buffer.py +204 -0
- deepagents_code/_env_vars.py +411 -0
- deepagents_code/_fake_models.py +66 -0
- deepagents_code/_git.py +521 -0
- deepagents_code/_paths.py +69 -0
- deepagents_code/_server_config.py +576 -0
- deepagents_code/_session_stats.py +235 -0
- deepagents_code/_startup_error.py +45 -0
- deepagents_code/_testing_models.py +305 -0
- deepagents_code/_textual_patches.py +420 -0
- deepagents_code/_tool_stream.py +694 -0
- deepagents_code/_version.py +46 -0
- deepagents_code/agent.py +1976 -0
- deepagents_code/app.py +19239 -0
- deepagents_code/app.tcss +438 -0
- deepagents_code/approval_mode.py +131 -0
- deepagents_code/ask_user.py +306 -0
- deepagents_code/auth_display.py +185 -0
- deepagents_code/auth_store.py +545 -0
- deepagents_code/built_in_skills/__init__.py +5 -0
- deepagents_code/built_in_skills/remember/SKILL.md +118 -0
- deepagents_code/built_in_skills/skill-creator/SKILL.md +383 -0
- deepagents_code/built_in_skills/skill-creator/scripts/init_skill.py +366 -0
- deepagents_code/built_in_skills/skill-creator/scripts/quick_validate.py +158 -0
- deepagents_code/client/__init__.py +1 -0
- deepagents_code/client/commands/__init__.py +1 -0
- deepagents_code/client/commands/auth.py +541 -0
- deepagents_code/client/commands/config.py +714 -0
- deepagents_code/client/commands/mcp.py +250 -0
- deepagents_code/client/commands/tools.py +416 -0
- deepagents_code/client/launch/__init__.py +1 -0
- deepagents_code/client/launch/server.py +978 -0
- deepagents_code/client/launch/server_manager.py +540 -0
- deepagents_code/client/non_interactive.py +1758 -0
- deepagents_code/client/remote_client.py +794 -0
- deepagents_code/clipboard.py +217 -0
- deepagents_code/command_registry.py +450 -0
- deepagents_code/config.py +4829 -0
- deepagents_code/config_manifest.py +1451 -0
- deepagents_code/configurable_model.py +577 -0
- deepagents_code/default_agent_prompt.md +12 -0
- deepagents_code/doctor.py +559 -0
- deepagents_code/editor.py +142 -0
- deepagents_code/event_bus.py +411 -0
- deepagents_code/extras_info.py +661 -0
- deepagents_code/file_ops.py +576 -0
- deepagents_code/formatting.py +135 -0
- deepagents_code/goal_rubric.py +497 -0
- deepagents_code/goal_tools.py +459 -0
- deepagents_code/hooks.py +348 -0
- deepagents_code/input.py +1041 -0
- deepagents_code/integrations/__init__.py +1 -0
- deepagents_code/integrations/openai_codex.py +551 -0
- deepagents_code/integrations/sandbox_config.py +198 -0
- deepagents_code/integrations/sandbox_factory.py +1124 -0
- deepagents_code/integrations/sandbox_provider.py +137 -0
- deepagents_code/integrations/sandbox_registry.py +350 -0
- deepagents_code/iterm_cursor_guide.py +176 -0
- deepagents_code/local_context.py +926 -0
- deepagents_code/main.py +3985 -0
- deepagents_code/managed_tools.py +642 -0
- deepagents_code/mcp_auth.py +1950 -0
- deepagents_code/mcp_config.py +176 -0
- deepagents_code/mcp_disabled.py +212 -0
- deepagents_code/mcp_login_service.py +281 -0
- deepagents_code/mcp_oauth_ui.py +199 -0
- deepagents_code/mcp_providers/__init__.py +23 -0
- deepagents_code/mcp_providers/_registry.py +39 -0
- deepagents_code/mcp_providers/base.py +133 -0
- deepagents_code/mcp_providers/github.py +102 -0
- deepagents_code/mcp_providers/slack.py +175 -0
- deepagents_code/mcp_tools.py +2427 -0
- deepagents_code/mcp_trust.py +207 -0
- deepagents_code/media_utils.py +826 -0
- deepagents_code/memory_guard.py +474 -0
- deepagents_code/model_config.py +4156 -0
- deepagents_code/notifications.py +247 -0
- deepagents_code/offload.py +402 -0
- deepagents_code/onboarding.py +223 -0
- deepagents_code/output.py +69 -0
- deepagents_code/paste_collapse.py +103 -0
- deepagents_code/project_utils.py +231 -0
- deepagents_code/py.typed +0 -0
- deepagents_code/reasoning_effort.py +641 -0
- deepagents_code/reliable_rubric.py +97 -0
- deepagents_code/resume_state.py +237 -0
- deepagents_code/server_graph.py +310 -0
- deepagents_code/session_end_summary.py +329 -0
- deepagents_code/sessions.py +1716 -0
- deepagents_code/skills/__init__.py +18 -0
- deepagents_code/skills/commands.py +1252 -0
- deepagents_code/skills/invocation.py +112 -0
- deepagents_code/skills/load.py +196 -0
- deepagents_code/skills/trust.py +547 -0
- deepagents_code/state_migration.py +136 -0
- deepagents_code/subagents.py +278 -0
- deepagents_code/system_prompt.md +204 -0
- deepagents_code/terminal_capabilities.py +115 -0
- deepagents_code/terminal_escape.py +287 -0
- deepagents_code/theme.py +891 -0
- deepagents_code/todo_list_prompt.md +12 -0
- deepagents_code/tool_catalog.py +509 -0
- deepagents_code/tool_display.py +367 -0
- deepagents_code/tools.py +516 -0
- deepagents_code/tui/__init__.py +1 -0
- deepagents_code/tui/textual_adapter.py +2553 -0
- deepagents_code/tui/widgets/__init__.py +9 -0
- deepagents_code/tui/widgets/_js_eval_display.py +139 -0
- deepagents_code/tui/widgets/_links.py +261 -0
- deepagents_code/tui/widgets/agent_selector.py +420 -0
- deepagents_code/tui/widgets/approval.py +602 -0
- deepagents_code/tui/widgets/ask_user.py +515 -0
- deepagents_code/tui/widgets/auth.py +1997 -0
- deepagents_code/tui/widgets/autocomplete.py +890 -0
- deepagents_code/tui/widgets/chat_input.py +3181 -0
- deepagents_code/tui/widgets/codex_auth.py +452 -0
- deepagents_code/tui/widgets/cwd_switch.py +242 -0
- deepagents_code/tui/widgets/debug_console.py +868 -0
- deepagents_code/tui/widgets/diff.py +252 -0
- deepagents_code/tui/widgets/effort_selector.py +189 -0
- deepagents_code/tui/widgets/goal_review.py +390 -0
- deepagents_code/tui/widgets/goal_status.py +50 -0
- deepagents_code/tui/widgets/history.py +194 -0
- deepagents_code/tui/widgets/install_confirm.py +248 -0
- deepagents_code/tui/widgets/launch_init.py +482 -0
- deepagents_code/tui/widgets/loading.py +227 -0
- deepagents_code/tui/widgets/mcp_login.py +539 -0
- deepagents_code/tui/widgets/mcp_reconnect.py +212 -0
- deepagents_code/tui/widgets/mcp_viewer.py +1634 -0
- deepagents_code/tui/widgets/message_store.py +999 -0
- deepagents_code/tui/widgets/messages.py +3846 -0
- deepagents_code/tui/widgets/model_selector.py +2343 -0
- deepagents_code/tui/widgets/notification_center.py +456 -0
- deepagents_code/tui/widgets/notification_detail.py +257 -0
- deepagents_code/tui/widgets/notification_settings.py +170 -0
- deepagents_code/tui/widgets/restart_prompt.py +158 -0
- deepagents_code/tui/widgets/skill_trust.py +131 -0
- deepagents_code/tui/widgets/startup_tip.py +91 -0
- deepagents_code/tui/widgets/status.py +781 -0
- deepagents_code/tui/widgets/subagent_panel.py +969 -0
- deepagents_code/tui/widgets/theme_selector.py +401 -0
- deepagents_code/tui/widgets/thread_selector.py +2564 -0
- deepagents_code/tui/widgets/tool_renderers.py +190 -0
- deepagents_code/tui/widgets/tool_widgets.py +304 -0
- deepagents_code/tui/widgets/update_available.py +311 -0
- deepagents_code/tui/widgets/update_confirm.py +184 -0
- deepagents_code/tui/widgets/update_progress.py +327 -0
- deepagents_code/tui/widgets/welcome.py +508 -0
- deepagents_code/turn_end_summary.py +522 -0
- deepagents_code/ui.py +858 -0
- deepagents_code/unicode_security.py +563 -0
- deepagents_code/update_check.py +3124 -0
- zjcode-0.0.1.data/data/deepagents_code/default_agent_prompt.md +12 -0
- zjcode-0.0.1.dist-info/METADATA +220 -0
- zjcode-0.0.1.dist-info/RECORD +163 -0
- zjcode-0.0.1.dist-info/WHEEL +4 -0
- zjcode-0.0.1.dist-info/entry_points.txt +2 -0
deepagents_code/_git.py
ADDED
|
@@ -0,0 +1,521 @@
|
|
|
1
|
+
"""Lightweight git metadata helpers for state detection."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import re
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import NamedTuple
|
|
9
|
+
from urllib.parse import urlparse
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
_GIT_DIR_POINTER_PREFIX = "gitdir: "
|
|
14
|
+
"""Prefix used by worktree-style `.git` files to point at the real git dir."""
|
|
15
|
+
|
|
16
|
+
_GIT_HEAD_REF_PREFIX = "ref: "
|
|
17
|
+
"""Prefix used by `HEAD` when it points at a named ref instead of a commit."""
|
|
18
|
+
|
|
19
|
+
_GIT_REF_PREFIXES = ("refs/heads/", "refs/remotes/", "refs/tags/", "refs/")
|
|
20
|
+
"""Known git ref prefixes stripped when formatting a branch-like display name."""
|
|
21
|
+
|
|
22
|
+
_git_dir_cache: dict[str, Path] = {}
|
|
23
|
+
"""Positive-only cache of resolved git metadata directories keyed by lookup path."""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _abbreviate_git_ref(ref: str) -> str:
|
|
27
|
+
"""Convert a full git ref into a short display name.
|
|
28
|
+
|
|
29
|
+
Args:
|
|
30
|
+
ref: Full git ref name from repository metadata.
|
|
31
|
+
|
|
32
|
+
Returns:
|
|
33
|
+
The abbreviated ref name suitable for display.
|
|
34
|
+
"""
|
|
35
|
+
for prefix in _GIT_REF_PREFIXES:
|
|
36
|
+
if ref.startswith(prefix):
|
|
37
|
+
return ref.removeprefix(prefix)
|
|
38
|
+
return ref
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _parse_git_dir_pointer(git_entry: Path) -> Path | None:
|
|
42
|
+
"""Resolve a `.git` file containing a `gitdir:` pointer.
|
|
43
|
+
|
|
44
|
+
Args:
|
|
45
|
+
git_entry: `.git` file to parse.
|
|
46
|
+
|
|
47
|
+
Returns:
|
|
48
|
+
The resolved git directory path, or `None` if the file is not a valid
|
|
49
|
+
gitdir pointer.
|
|
50
|
+
"""
|
|
51
|
+
try:
|
|
52
|
+
raw = git_entry.read_text(encoding="utf-8").strip()
|
|
53
|
+
except OSError:
|
|
54
|
+
logger.debug("Failed to read gitdir pointer from %s", git_entry, exc_info=True)
|
|
55
|
+
return None
|
|
56
|
+
|
|
57
|
+
if not raw.startswith(_GIT_DIR_POINTER_PREFIX):
|
|
58
|
+
return None
|
|
59
|
+
|
|
60
|
+
pointer = raw.removeprefix(_GIT_DIR_POINTER_PREFIX).strip()
|
|
61
|
+
if not pointer:
|
|
62
|
+
return None
|
|
63
|
+
|
|
64
|
+
git_dir = Path(pointer)
|
|
65
|
+
if not git_dir.is_absolute():
|
|
66
|
+
git_dir = git_entry.parent / git_dir
|
|
67
|
+
return git_dir.resolve(strict=False)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _normalize_lookup_path(path: str | Path) -> Path:
|
|
71
|
+
"""Normalize a lookup path for git metadata discovery.
|
|
72
|
+
|
|
73
|
+
Args:
|
|
74
|
+
path: Directory or file path inside a repository.
|
|
75
|
+
|
|
76
|
+
Returns:
|
|
77
|
+
A normalized absolute path when possible, or the expanded path if full
|
|
78
|
+
resolution fails.
|
|
79
|
+
"""
|
|
80
|
+
try:
|
|
81
|
+
return Path(path).expanduser().resolve(strict=False)
|
|
82
|
+
except OSError:
|
|
83
|
+
return Path(path).expanduser()
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _find_git_dir_uncached(path: Path) -> Path | None:
|
|
87
|
+
"""Locate the effective git metadata directory without using caches.
|
|
88
|
+
|
|
89
|
+
Args:
|
|
90
|
+
path: Normalized directory or file path inside a repository.
|
|
91
|
+
|
|
92
|
+
Returns:
|
|
93
|
+
The git metadata directory for the repository containing `path`, or
|
|
94
|
+
`None` when no repository can be identified.
|
|
95
|
+
"""
|
|
96
|
+
current = path
|
|
97
|
+
if not current.is_dir():
|
|
98
|
+
current = current.parent
|
|
99
|
+
|
|
100
|
+
for directory in (current, *current.parents):
|
|
101
|
+
git_entry = directory / ".git"
|
|
102
|
+
if git_entry.is_dir():
|
|
103
|
+
return git_entry
|
|
104
|
+
if git_entry.is_file():
|
|
105
|
+
git_dir = _parse_git_dir_pointer(git_entry)
|
|
106
|
+
if git_dir is not None and git_dir.is_dir():
|
|
107
|
+
return git_dir
|
|
108
|
+
return None
|
|
109
|
+
|
|
110
|
+
return None
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def find_git_dir(path: str | Path) -> Path | None:
|
|
114
|
+
"""Locate the effective git metadata directory for a path.
|
|
115
|
+
|
|
116
|
+
Args:
|
|
117
|
+
path: Directory or file path inside a repository.
|
|
118
|
+
|
|
119
|
+
Returns:
|
|
120
|
+
The git metadata directory for the repository containing `path`, or
|
|
121
|
+
`None` when no repository can be identified.
|
|
122
|
+
"""
|
|
123
|
+
current = _normalize_lookup_path(path)
|
|
124
|
+
key = str(current)
|
|
125
|
+
cached = _git_dir_cache.get(key)
|
|
126
|
+
if cached is not None:
|
|
127
|
+
return cached
|
|
128
|
+
|
|
129
|
+
git_dir = _find_git_dir_uncached(current)
|
|
130
|
+
if git_dir is not None:
|
|
131
|
+
_git_dir_cache[key] = git_dir
|
|
132
|
+
return git_dir
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def find_git_root(path: str | Path) -> Path | None:
|
|
136
|
+
"""Locate the repository root for a path.
|
|
137
|
+
|
|
138
|
+
Args:
|
|
139
|
+
path: Directory or file path inside a repository.
|
|
140
|
+
|
|
141
|
+
Returns:
|
|
142
|
+
The repository root containing `path`, or `None` when no repository can
|
|
143
|
+
be identified.
|
|
144
|
+
"""
|
|
145
|
+
current = _normalize_lookup_path(path)
|
|
146
|
+
if not current.is_dir():
|
|
147
|
+
current = current.parent
|
|
148
|
+
|
|
149
|
+
for directory in (current, *current.parents):
|
|
150
|
+
git_entry = directory / ".git"
|
|
151
|
+
if git_entry.is_dir():
|
|
152
|
+
return directory
|
|
153
|
+
if git_entry.is_file():
|
|
154
|
+
git_dir = _parse_git_dir_pointer(git_entry)
|
|
155
|
+
if git_dir is not None and git_dir.is_dir():
|
|
156
|
+
return directory
|
|
157
|
+
return None
|
|
158
|
+
|
|
159
|
+
return None
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def read_git_branch_from_filesystem(path: str | Path) -> str | None:
|
|
163
|
+
"""Read the current git branch from repository metadata.
|
|
164
|
+
|
|
165
|
+
Args:
|
|
166
|
+
path: Directory or file path inside a repository.
|
|
167
|
+
|
|
168
|
+
Returns:
|
|
169
|
+
The abbreviated branch name, `HEAD` for detached HEAD, an empty string
|
|
170
|
+
when `path` is not inside a git repository, or `None` when metadata
|
|
171
|
+
exists but cannot be parsed confidently.
|
|
172
|
+
"""
|
|
173
|
+
git_dir = find_git_dir(path)
|
|
174
|
+
if git_dir is None:
|
|
175
|
+
return ""
|
|
176
|
+
|
|
177
|
+
head_path = git_dir / "HEAD"
|
|
178
|
+
try:
|
|
179
|
+
head = head_path.read_text(encoding="utf-8").strip()
|
|
180
|
+
except FileNotFoundError:
|
|
181
|
+
logger.debug("Git HEAD file not found in %s", git_dir)
|
|
182
|
+
return None
|
|
183
|
+
except OSError:
|
|
184
|
+
logger.debug("Failed to read git HEAD from %s", git_dir, exc_info=True)
|
|
185
|
+
return None
|
|
186
|
+
|
|
187
|
+
if not head:
|
|
188
|
+
return ""
|
|
189
|
+
if head.startswith(_GIT_HEAD_REF_PREFIX):
|
|
190
|
+
ref = head.removeprefix(_GIT_HEAD_REF_PREFIX).strip()
|
|
191
|
+
return _abbreviate_git_ref(ref) if ref else None
|
|
192
|
+
return "HEAD"
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def read_git_branch_via_subprocess(path: str | Path) -> str:
|
|
196
|
+
"""Fall back to `git rev-parse` for unusual repository layouts.
|
|
197
|
+
|
|
198
|
+
Args:
|
|
199
|
+
path: Directory or file path inside a repository.
|
|
200
|
+
|
|
201
|
+
Returns:
|
|
202
|
+
The branch name reported by git, or an empty string on failure.
|
|
203
|
+
"""
|
|
204
|
+
import subprocess # noqa: S404 # stdlib subprocess fallback
|
|
205
|
+
|
|
206
|
+
try:
|
|
207
|
+
result = subprocess.run(
|
|
208
|
+
["git", "rev-parse", "--abbrev-ref", "HEAD"], # noqa: S607
|
|
209
|
+
capture_output=True,
|
|
210
|
+
text=True,
|
|
211
|
+
timeout=2,
|
|
212
|
+
check=False,
|
|
213
|
+
cwd=path,
|
|
214
|
+
)
|
|
215
|
+
if result.returncode == 0:
|
|
216
|
+
return result.stdout.strip()
|
|
217
|
+
except FileNotFoundError:
|
|
218
|
+
pass # git not installed
|
|
219
|
+
except subprocess.TimeoutExpired:
|
|
220
|
+
logger.debug("Git branch detection timed out")
|
|
221
|
+
except OSError:
|
|
222
|
+
logger.debug("Git branch detection failed", exc_info=True)
|
|
223
|
+
return ""
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def resolve_git_branch(path: str | Path) -> str:
|
|
227
|
+
"""Resolve the current git branch with a filesystem-first strategy.
|
|
228
|
+
|
|
229
|
+
Args:
|
|
230
|
+
path: Directory or file path inside a repository.
|
|
231
|
+
|
|
232
|
+
Returns:
|
|
233
|
+
The current branch name, `HEAD` for detached HEAD, or an empty string
|
|
234
|
+
when no branch can be determined.
|
|
235
|
+
"""
|
|
236
|
+
branch = read_git_branch_from_filesystem(path)
|
|
237
|
+
if branch is not None:
|
|
238
|
+
return branch
|
|
239
|
+
return read_git_branch_via_subprocess(path)
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
_GIT_SHA_RE = re.compile(r"\A[0-9a-f]{40}(?:[0-9a-f]{24})?\Z")
|
|
243
|
+
"""Matches a full 40-char SHA-1 (or 64-char SHA-256) git object id."""
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def read_git_commit_sha_from_filesystem(path: str | Path) -> str | None:
|
|
247
|
+
"""Read the current `HEAD` commit SHA from repository metadata.
|
|
248
|
+
|
|
249
|
+
Resolves a symbolic `HEAD` (`ref: refs/heads/<branch>`) by reading the
|
|
250
|
+
loose ref file, then `packed-refs`. A detached `HEAD` already contains the
|
|
251
|
+
raw SHA.
|
|
252
|
+
|
|
253
|
+
Args:
|
|
254
|
+
path: Directory or file path inside a repository.
|
|
255
|
+
|
|
256
|
+
Returns:
|
|
257
|
+
The full commit SHA (40-char SHA-1 or 64-char SHA-256), an empty string
|
|
258
|
+
when `path` is not inside a git repository, or `None` when metadata
|
|
259
|
+
exists but cannot be resolved.
|
|
260
|
+
"""
|
|
261
|
+
git_dir = find_git_dir(path)
|
|
262
|
+
if git_dir is None:
|
|
263
|
+
return ""
|
|
264
|
+
|
|
265
|
+
head_path = git_dir / "HEAD"
|
|
266
|
+
try:
|
|
267
|
+
head = head_path.read_text(encoding="utf-8").strip()
|
|
268
|
+
except FileNotFoundError:
|
|
269
|
+
logger.debug("Git HEAD file not found in %s", git_dir)
|
|
270
|
+
return None
|
|
271
|
+
except OSError:
|
|
272
|
+
logger.debug("Failed to read git HEAD from %s", git_dir, exc_info=True)
|
|
273
|
+
return None
|
|
274
|
+
|
|
275
|
+
if not head:
|
|
276
|
+
return None
|
|
277
|
+
if not head.startswith(_GIT_HEAD_REF_PREFIX):
|
|
278
|
+
# Detached HEAD: the file holds the commit SHA directly.
|
|
279
|
+
return head if _GIT_SHA_RE.match(head) else None
|
|
280
|
+
|
|
281
|
+
ref = head.removeprefix(_GIT_HEAD_REF_PREFIX).strip()
|
|
282
|
+
if not ref:
|
|
283
|
+
return None
|
|
284
|
+
|
|
285
|
+
loose_ref = git_dir / Path(ref)
|
|
286
|
+
try:
|
|
287
|
+
sha = loose_ref.read_text(encoding="utf-8").strip()
|
|
288
|
+
if _GIT_SHA_RE.match(sha):
|
|
289
|
+
return sha
|
|
290
|
+
except FileNotFoundError:
|
|
291
|
+
pass # ref may be packed
|
|
292
|
+
except OSError:
|
|
293
|
+
logger.debug("Failed to read loose ref %s", loose_ref, exc_info=True)
|
|
294
|
+
return None
|
|
295
|
+
|
|
296
|
+
return _read_packed_ref(git_dir, ref)
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def _read_packed_ref(git_dir: Path, ref: str) -> str | None:
|
|
300
|
+
"""Resolve a ref to its SHA from `packed-refs`.
|
|
301
|
+
|
|
302
|
+
Args:
|
|
303
|
+
git_dir: Resolved git metadata directory.
|
|
304
|
+
ref: Full ref name (e.g. `refs/heads/main`).
|
|
305
|
+
|
|
306
|
+
Returns:
|
|
307
|
+
The full commit SHA, or `None` when the ref is absent or unreadable.
|
|
308
|
+
"""
|
|
309
|
+
try:
|
|
310
|
+
packed = (git_dir / "packed-refs").read_text(encoding="utf-8")
|
|
311
|
+
except FileNotFoundError:
|
|
312
|
+
return None
|
|
313
|
+
except OSError:
|
|
314
|
+
logger.debug("Failed to read packed-refs in %s", git_dir, exc_info=True)
|
|
315
|
+
return None
|
|
316
|
+
|
|
317
|
+
for line in packed.splitlines():
|
|
318
|
+
if not line or line.startswith(("#", "^")):
|
|
319
|
+
continue
|
|
320
|
+
sha, _, name = line.partition(" ")
|
|
321
|
+
if name.strip() == ref and _GIT_SHA_RE.match(sha):
|
|
322
|
+
return sha
|
|
323
|
+
return None
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def read_git_commit_sha_via_subprocess(path: str | Path) -> str:
|
|
327
|
+
"""Fall back to `git rev-parse HEAD` for unusual repository layouts.
|
|
328
|
+
|
|
329
|
+
Args:
|
|
330
|
+
path: Directory or file path inside a repository.
|
|
331
|
+
|
|
332
|
+
Returns:
|
|
333
|
+
The full commit SHA reported by git, or an empty string on failure.
|
|
334
|
+
"""
|
|
335
|
+
import subprocess # noqa: S404 # stdlib subprocess fallback
|
|
336
|
+
|
|
337
|
+
try:
|
|
338
|
+
result = subprocess.run(
|
|
339
|
+
["git", "rev-parse", "HEAD"], # noqa: S607
|
|
340
|
+
capture_output=True,
|
|
341
|
+
text=True,
|
|
342
|
+
timeout=2,
|
|
343
|
+
check=False,
|
|
344
|
+
cwd=path,
|
|
345
|
+
)
|
|
346
|
+
if result.returncode == 0:
|
|
347
|
+
return result.stdout.strip()
|
|
348
|
+
except FileNotFoundError:
|
|
349
|
+
pass # git not installed
|
|
350
|
+
except subprocess.TimeoutExpired:
|
|
351
|
+
logger.debug("Git commit detection timed out")
|
|
352
|
+
except OSError:
|
|
353
|
+
logger.debug("Git commit detection failed", exc_info=True)
|
|
354
|
+
return ""
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def resolve_git_commit_sha(path: str | Path) -> str:
|
|
358
|
+
"""Resolve the current `HEAD` commit SHA, filesystem-first.
|
|
359
|
+
|
|
360
|
+
Args:
|
|
361
|
+
path: Directory or file path inside a repository.
|
|
362
|
+
|
|
363
|
+
Returns:
|
|
364
|
+
The full commit SHA, or an empty string when none can be determined.
|
|
365
|
+
"""
|
|
366
|
+
sha = read_git_commit_sha_from_filesystem(path)
|
|
367
|
+
if sha is not None:
|
|
368
|
+
return sha
|
|
369
|
+
return read_git_commit_sha_via_subprocess(path)
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def read_git_remote_url_from_filesystem(path: str | Path) -> str | None:
|
|
373
|
+
"""Read the `origin` remote URL from the repository `config` file.
|
|
374
|
+
|
|
375
|
+
Args:
|
|
376
|
+
path: Directory or file path inside a repository.
|
|
377
|
+
|
|
378
|
+
Returns:
|
|
379
|
+
The `origin` remote URL, an empty string when `path` is not inside a
|
|
380
|
+
git repository, or `None` when no `origin` URL is configured.
|
|
381
|
+
"""
|
|
382
|
+
git_dir = find_git_dir(path)
|
|
383
|
+
if git_dir is None:
|
|
384
|
+
return ""
|
|
385
|
+
|
|
386
|
+
try:
|
|
387
|
+
raw = (git_dir / "config").read_text(encoding="utf-8")
|
|
388
|
+
except FileNotFoundError:
|
|
389
|
+
return None
|
|
390
|
+
except OSError:
|
|
391
|
+
logger.debug("Failed to read git config in %s", git_dir, exc_info=True)
|
|
392
|
+
return None
|
|
393
|
+
|
|
394
|
+
in_origin = False
|
|
395
|
+
for line in raw.splitlines():
|
|
396
|
+
stripped = line.strip()
|
|
397
|
+
if stripped.startswith("[") and stripped.endswith("]"):
|
|
398
|
+
# Section header, e.g. [remote "origin"]. Match case-insensitively
|
|
399
|
+
# on the remote name to mirror git's own behavior.
|
|
400
|
+
in_origin = stripped.replace(" ", "").lower() == '[remote"origin"]'
|
|
401
|
+
continue
|
|
402
|
+
if in_origin and stripped.lower().startswith("url"):
|
|
403
|
+
_, _, value = stripped.partition("=")
|
|
404
|
+
url = value.strip()
|
|
405
|
+
if url:
|
|
406
|
+
return url
|
|
407
|
+
return None
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
def read_git_remote_url_via_subprocess(path: str | Path) -> str:
|
|
411
|
+
"""Fall back to `git config --get remote.origin.url`.
|
|
412
|
+
|
|
413
|
+
Args:
|
|
414
|
+
path: Directory or file path inside a repository.
|
|
415
|
+
|
|
416
|
+
Returns:
|
|
417
|
+
The `origin` remote URL reported by git, or an empty string on failure.
|
|
418
|
+
"""
|
|
419
|
+
import subprocess # noqa: S404 # stdlib subprocess fallback
|
|
420
|
+
|
|
421
|
+
try:
|
|
422
|
+
result = subprocess.run(
|
|
423
|
+
["git", "config", "--get", "remote.origin.url"], # noqa: S607
|
|
424
|
+
capture_output=True,
|
|
425
|
+
text=True,
|
|
426
|
+
timeout=2,
|
|
427
|
+
check=False,
|
|
428
|
+
cwd=path,
|
|
429
|
+
)
|
|
430
|
+
if result.returncode == 0:
|
|
431
|
+
return result.stdout.strip()
|
|
432
|
+
except FileNotFoundError:
|
|
433
|
+
pass # git not installed
|
|
434
|
+
except subprocess.TimeoutExpired:
|
|
435
|
+
logger.debug("Git remote detection timed out")
|
|
436
|
+
except OSError:
|
|
437
|
+
logger.debug("Git remote detection failed", exc_info=True)
|
|
438
|
+
return ""
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
def resolve_git_remote_url(path: str | Path) -> str:
|
|
442
|
+
"""Resolve the `origin` remote URL, filesystem-first.
|
|
443
|
+
|
|
444
|
+
Args:
|
|
445
|
+
path: Directory or file path inside a repository.
|
|
446
|
+
|
|
447
|
+
Returns:
|
|
448
|
+
The `origin` remote URL, or an empty string when none can be determined.
|
|
449
|
+
"""
|
|
450
|
+
url = read_git_remote_url_from_filesystem(path)
|
|
451
|
+
if url is not None:
|
|
452
|
+
return url
|
|
453
|
+
return read_git_remote_url_via_subprocess(path)
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
_REPO_PROVIDERS: dict[str, str] = {
|
|
457
|
+
"github.com": "github",
|
|
458
|
+
"gitlab.com": "gitlab",
|
|
459
|
+
"bitbucket.org": "bitbucket",
|
|
460
|
+
}
|
|
461
|
+
"""Maps a known git host to its `repository_provider` slug."""
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
class RepositoryMetadata(NamedTuple):
|
|
465
|
+
"""Parsed `origin` remote attribution for coding-agent-v1 traces.
|
|
466
|
+
|
|
467
|
+
A `NamedTuple` so callers can still unpack positionally or index, while the
|
|
468
|
+
field names keep the slot order from being load-bearing at every call site.
|
|
469
|
+
"""
|
|
470
|
+
|
|
471
|
+
url: str
|
|
472
|
+
"""Normalized `https://<host>/<org>/<repo>` URL (credentials stripped)."""
|
|
473
|
+
|
|
474
|
+
provider: str
|
|
475
|
+
"""Provider slug: `github`, `gitlab`, `bitbucket`, or `other`."""
|
|
476
|
+
|
|
477
|
+
name: str
|
|
478
|
+
"""`org/repo` name (may be nested, e.g. `group/subgroup/project`)."""
|
|
479
|
+
|
|
480
|
+
|
|
481
|
+
def parse_repository_metadata(remote_url: str) -> RepositoryMetadata | None:
|
|
482
|
+
"""Derive repository attribution from an `origin` remote URL.
|
|
483
|
+
|
|
484
|
+
Handles both HTTPS (`https://github.com/org/repo.git`) and scp-style SSH
|
|
485
|
+
(`git@github.com:org/repo.git`) remotes, normalizing the URL to its
|
|
486
|
+
canonical `https://<host>/<org>/<repo>` form and stripping any embedded
|
|
487
|
+
credentials.
|
|
488
|
+
|
|
489
|
+
Args:
|
|
490
|
+
remote_url: The raw `origin` remote URL.
|
|
491
|
+
|
|
492
|
+
Returns:
|
|
493
|
+
A `RepositoryMetadata` of the normalized URL, provider slug
|
|
494
|
+
(`github`/`gitlab`/`bitbucket`/`other`), and `org/repo` name,
|
|
495
|
+
or `None` when the URL cannot be parsed.
|
|
496
|
+
"""
|
|
497
|
+
url = (remote_url or "").strip()
|
|
498
|
+
if not url:
|
|
499
|
+
return None
|
|
500
|
+
|
|
501
|
+
host: str
|
|
502
|
+
repo_path: str
|
|
503
|
+
if "://" in url:
|
|
504
|
+
parsed = urlparse(url)
|
|
505
|
+
host = (parsed.hostname or "").lower()
|
|
506
|
+
repo_path = parsed.path.lstrip("/")
|
|
507
|
+
else:
|
|
508
|
+
# scp-style: [user@]host:org/repo(.git)
|
|
509
|
+
userhost, sep, repo_path = url.partition(":")
|
|
510
|
+
if not sep:
|
|
511
|
+
return None
|
|
512
|
+
host = userhost.rsplit("@", 1)[-1].lower()
|
|
513
|
+
repo_path = repo_path.lstrip("/")
|
|
514
|
+
|
|
515
|
+
repo_path = repo_path.strip("/").removesuffix(".git").strip("/")
|
|
516
|
+
if not host or not repo_path:
|
|
517
|
+
return None
|
|
518
|
+
|
|
519
|
+
provider = _REPO_PROVIDERS.get(host, "other")
|
|
520
|
+
normalized_url = f"https://{host}/{repo_path}"
|
|
521
|
+
return RepositoryMetadata(normalized_url, provider, repo_path)
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""Filesystem-path classification shared by the diagnostic CLI commands.
|
|
2
|
+
|
|
3
|
+
`dcode doctor` and `dcode config path` both probe whether config locations
|
|
4
|
+
exist. `Path.exists()` can report `False` for `OSError` cases such as EACCES
|
|
5
|
+
when a parent directory denies traversal, so a bare `.exists()` can hide the
|
|
6
|
+
very permissions problem these commands should diagnose. `classify_path`
|
|
7
|
+
centralizes the guard and reports an unreadable path as a distinct state instead
|
|
8
|
+
of conflating it with "missing".
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import errno
|
|
14
|
+
import logging
|
|
15
|
+
from enum import StrEnum
|
|
16
|
+
from typing import TYPE_CHECKING
|
|
17
|
+
|
|
18
|
+
if TYPE_CHECKING:
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
logger = logging.getLogger(__name__)
|
|
22
|
+
|
|
23
|
+
_MISSING_ERRNOS = {errno.ENOENT, errno.ENOTDIR}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class PathState(StrEnum):
|
|
27
|
+
"""Whether a probed path exists, is absent, or could not be read.
|
|
28
|
+
|
|
29
|
+
A `StrEnum` so the value serializes directly to JSON without a custom
|
|
30
|
+
encoder.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
EXISTS = "exists"
|
|
34
|
+
"""The path is present on disk."""
|
|
35
|
+
|
|
36
|
+
MISSING = "missing"
|
|
37
|
+
"""The path is absent (and its parents are readable)."""
|
|
38
|
+
|
|
39
|
+
UNREADABLE = "unreadable"
|
|
40
|
+
"""Existence could not be determined because `Path.stat()` raised.
|
|
41
|
+
|
|
42
|
+
Typically EACCES when a parent directory denies traversal. Kept distinct
|
|
43
|
+
from `MISSING` so diagnostics can flag it as a genuine problem rather than
|
|
44
|
+
a not-yet-created path.
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def classify_path(path: Path) -> PathState:
|
|
49
|
+
"""Classify a path as existing, missing, or unreadable.
|
|
50
|
+
|
|
51
|
+
Args:
|
|
52
|
+
path: Filesystem path to probe.
|
|
53
|
+
|
|
54
|
+
Returns:
|
|
55
|
+
`PathState.EXISTS` for a present path, `PathState.MISSING` for expected
|
|
56
|
+
absent-path errors, and `PathState.UNREADABLE` when `Path.stat()`
|
|
57
|
+
raises another `OSError` (e.g. a parent directory denies traversal).
|
|
58
|
+
The error is logged at debug level so an unreadable path is never
|
|
59
|
+
silently indistinguishable from a missing one.
|
|
60
|
+
"""
|
|
61
|
+
try:
|
|
62
|
+
path.stat()
|
|
63
|
+
except OSError as exc:
|
|
64
|
+
if exc.errno in _MISSING_ERRNOS:
|
|
65
|
+
return PathState.MISSING
|
|
66
|
+
logger.debug("Could not stat %s", path, exc_info=True)
|
|
67
|
+
return PathState.UNREADABLE
|
|
68
|
+
else:
|
|
69
|
+
return PathState.EXISTS
|