agent-cli 0.70.5__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- agent_cli/__init__.py +5 -0
- agent_cli/__main__.py +6 -0
- agent_cli/_extras.json +14 -0
- agent_cli/_requirements/.gitkeep +0 -0
- agent_cli/_requirements/audio.txt +79 -0
- agent_cli/_requirements/faster-whisper.txt +215 -0
- agent_cli/_requirements/kokoro.txt +425 -0
- agent_cli/_requirements/llm.txt +183 -0
- agent_cli/_requirements/memory.txt +355 -0
- agent_cli/_requirements/mlx-whisper.txt +222 -0
- agent_cli/_requirements/piper.txt +176 -0
- agent_cli/_requirements/rag.txt +402 -0
- agent_cli/_requirements/server.txt +154 -0
- agent_cli/_requirements/speed.txt +77 -0
- agent_cli/_requirements/vad.txt +155 -0
- agent_cli/_requirements/wyoming.txt +71 -0
- agent_cli/_tools.py +368 -0
- agent_cli/agents/__init__.py +23 -0
- agent_cli/agents/_voice_agent_common.py +136 -0
- agent_cli/agents/assistant.py +383 -0
- agent_cli/agents/autocorrect.py +284 -0
- agent_cli/agents/chat.py +496 -0
- agent_cli/agents/memory/__init__.py +31 -0
- agent_cli/agents/memory/add.py +190 -0
- agent_cli/agents/memory/proxy.py +160 -0
- agent_cli/agents/rag_proxy.py +128 -0
- agent_cli/agents/speak.py +209 -0
- agent_cli/agents/transcribe.py +671 -0
- agent_cli/agents/transcribe_daemon.py +499 -0
- agent_cli/agents/voice_edit.py +291 -0
- agent_cli/api.py +22 -0
- agent_cli/cli.py +106 -0
- agent_cli/config.py +503 -0
- agent_cli/config_cmd.py +307 -0
- agent_cli/constants.py +27 -0
- agent_cli/core/__init__.py +1 -0
- agent_cli/core/audio.py +461 -0
- agent_cli/core/audio_format.py +299 -0
- agent_cli/core/chroma.py +88 -0
- agent_cli/core/deps.py +191 -0
- agent_cli/core/openai_proxy.py +139 -0
- agent_cli/core/process.py +195 -0
- agent_cli/core/reranker.py +120 -0
- agent_cli/core/sse.py +87 -0
- agent_cli/core/transcription_logger.py +70 -0
- agent_cli/core/utils.py +526 -0
- agent_cli/core/vad.py +175 -0
- agent_cli/core/watch.py +65 -0
- agent_cli/dev/__init__.py +14 -0
- agent_cli/dev/cli.py +1588 -0
- agent_cli/dev/coding_agents/__init__.py +19 -0
- agent_cli/dev/coding_agents/aider.py +24 -0
- agent_cli/dev/coding_agents/base.py +167 -0
- agent_cli/dev/coding_agents/claude.py +39 -0
- agent_cli/dev/coding_agents/codex.py +24 -0
- agent_cli/dev/coding_agents/continue_dev.py +15 -0
- agent_cli/dev/coding_agents/copilot.py +24 -0
- agent_cli/dev/coding_agents/cursor_agent.py +48 -0
- agent_cli/dev/coding_agents/gemini.py +28 -0
- agent_cli/dev/coding_agents/opencode.py +15 -0
- agent_cli/dev/coding_agents/registry.py +49 -0
- agent_cli/dev/editors/__init__.py +19 -0
- agent_cli/dev/editors/base.py +89 -0
- agent_cli/dev/editors/cursor.py +15 -0
- agent_cli/dev/editors/emacs.py +46 -0
- agent_cli/dev/editors/jetbrains.py +56 -0
- agent_cli/dev/editors/nano.py +31 -0
- agent_cli/dev/editors/neovim.py +33 -0
- agent_cli/dev/editors/registry.py +59 -0
- agent_cli/dev/editors/sublime.py +20 -0
- agent_cli/dev/editors/vim.py +42 -0
- agent_cli/dev/editors/vscode.py +15 -0
- agent_cli/dev/editors/zed.py +20 -0
- agent_cli/dev/project.py +568 -0
- agent_cli/dev/registry.py +52 -0
- agent_cli/dev/skill/SKILL.md +141 -0
- agent_cli/dev/skill/examples.md +571 -0
- agent_cli/dev/terminals/__init__.py +19 -0
- agent_cli/dev/terminals/apple_terminal.py +82 -0
- agent_cli/dev/terminals/base.py +56 -0
- agent_cli/dev/terminals/gnome.py +51 -0
- agent_cli/dev/terminals/iterm2.py +84 -0
- agent_cli/dev/terminals/kitty.py +77 -0
- agent_cli/dev/terminals/registry.py +48 -0
- agent_cli/dev/terminals/tmux.py +58 -0
- agent_cli/dev/terminals/warp.py +132 -0
- agent_cli/dev/terminals/zellij.py +78 -0
- agent_cli/dev/worktree.py +856 -0
- agent_cli/docs_gen.py +417 -0
- agent_cli/example-config.toml +185 -0
- agent_cli/install/__init__.py +5 -0
- agent_cli/install/common.py +89 -0
- agent_cli/install/extras.py +174 -0
- agent_cli/install/hotkeys.py +48 -0
- agent_cli/install/services.py +87 -0
- agent_cli/memory/__init__.py +7 -0
- agent_cli/memory/_files.py +250 -0
- agent_cli/memory/_filters.py +63 -0
- agent_cli/memory/_git.py +157 -0
- agent_cli/memory/_indexer.py +142 -0
- agent_cli/memory/_ingest.py +408 -0
- agent_cli/memory/_persistence.py +182 -0
- agent_cli/memory/_prompt.py +91 -0
- agent_cli/memory/_retrieval.py +294 -0
- agent_cli/memory/_store.py +169 -0
- agent_cli/memory/_streaming.py +44 -0
- agent_cli/memory/_tasks.py +48 -0
- agent_cli/memory/api.py +113 -0
- agent_cli/memory/client.py +272 -0
- agent_cli/memory/engine.py +361 -0
- agent_cli/memory/entities.py +43 -0
- agent_cli/memory/models.py +112 -0
- agent_cli/opts.py +433 -0
- agent_cli/py.typed +0 -0
- agent_cli/rag/__init__.py +3 -0
- agent_cli/rag/_indexer.py +67 -0
- agent_cli/rag/_indexing.py +226 -0
- agent_cli/rag/_prompt.py +30 -0
- agent_cli/rag/_retriever.py +156 -0
- agent_cli/rag/_store.py +48 -0
- agent_cli/rag/_utils.py +218 -0
- agent_cli/rag/api.py +175 -0
- agent_cli/rag/client.py +299 -0
- agent_cli/rag/engine.py +302 -0
- agent_cli/rag/models.py +55 -0
- agent_cli/scripts/.runtime/.gitkeep +0 -0
- agent_cli/scripts/__init__.py +1 -0
- agent_cli/scripts/check_plugin_skill_sync.py +50 -0
- agent_cli/scripts/linux-hotkeys/README.md +63 -0
- agent_cli/scripts/linux-hotkeys/toggle-autocorrect.sh +45 -0
- agent_cli/scripts/linux-hotkeys/toggle-transcription.sh +58 -0
- agent_cli/scripts/linux-hotkeys/toggle-voice-edit.sh +58 -0
- agent_cli/scripts/macos-hotkeys/README.md +45 -0
- agent_cli/scripts/macos-hotkeys/skhd-config-example +5 -0
- agent_cli/scripts/macos-hotkeys/toggle-autocorrect.sh +12 -0
- agent_cli/scripts/macos-hotkeys/toggle-transcription.sh +37 -0
- agent_cli/scripts/macos-hotkeys/toggle-voice-edit.sh +37 -0
- agent_cli/scripts/nvidia-asr-server/README.md +99 -0
- agent_cli/scripts/nvidia-asr-server/pyproject.toml +27 -0
- agent_cli/scripts/nvidia-asr-server/server.py +255 -0
- agent_cli/scripts/nvidia-asr-server/shell.nix +32 -0
- agent_cli/scripts/nvidia-asr-server/uv.lock +4654 -0
- agent_cli/scripts/run-openwakeword.sh +11 -0
- agent_cli/scripts/run-piper-windows.ps1 +30 -0
- agent_cli/scripts/run-piper.sh +24 -0
- agent_cli/scripts/run-whisper-linux.sh +40 -0
- agent_cli/scripts/run-whisper-macos.sh +6 -0
- agent_cli/scripts/run-whisper-windows.ps1 +51 -0
- agent_cli/scripts/run-whisper.sh +9 -0
- agent_cli/scripts/run_faster_whisper_server.py +136 -0
- agent_cli/scripts/setup-linux-hotkeys.sh +72 -0
- agent_cli/scripts/setup-linux.sh +108 -0
- agent_cli/scripts/setup-macos-hotkeys.sh +61 -0
- agent_cli/scripts/setup-macos.sh +76 -0
- agent_cli/scripts/setup-windows.ps1 +63 -0
- agent_cli/scripts/start-all-services-windows.ps1 +53 -0
- agent_cli/scripts/start-all-services.sh +178 -0
- agent_cli/scripts/sync_extras.py +138 -0
- agent_cli/server/__init__.py +3 -0
- agent_cli/server/cli.py +721 -0
- agent_cli/server/common.py +222 -0
- agent_cli/server/model_manager.py +288 -0
- agent_cli/server/model_registry.py +225 -0
- agent_cli/server/proxy/__init__.py +3 -0
- agent_cli/server/proxy/api.py +444 -0
- agent_cli/server/streaming.py +67 -0
- agent_cli/server/tts/__init__.py +3 -0
- agent_cli/server/tts/api.py +335 -0
- agent_cli/server/tts/backends/__init__.py +82 -0
- agent_cli/server/tts/backends/base.py +139 -0
- agent_cli/server/tts/backends/kokoro.py +403 -0
- agent_cli/server/tts/backends/piper.py +253 -0
- agent_cli/server/tts/model_manager.py +201 -0
- agent_cli/server/tts/model_registry.py +28 -0
- agent_cli/server/tts/wyoming_handler.py +249 -0
- agent_cli/server/whisper/__init__.py +3 -0
- agent_cli/server/whisper/api.py +413 -0
- agent_cli/server/whisper/backends/__init__.py +89 -0
- agent_cli/server/whisper/backends/base.py +97 -0
- agent_cli/server/whisper/backends/faster_whisper.py +225 -0
- agent_cli/server/whisper/backends/mlx.py +270 -0
- agent_cli/server/whisper/languages.py +116 -0
- agent_cli/server/whisper/model_manager.py +157 -0
- agent_cli/server/whisper/model_registry.py +28 -0
- agent_cli/server/whisper/wyoming_handler.py +203 -0
- agent_cli/services/__init__.py +343 -0
- agent_cli/services/_wyoming_utils.py +64 -0
- agent_cli/services/asr.py +506 -0
- agent_cli/services/llm.py +228 -0
- agent_cli/services/tts.py +450 -0
- agent_cli/services/wake_word.py +142 -0
- agent_cli-0.70.5.dist-info/METADATA +2118 -0
- agent_cli-0.70.5.dist-info/RECORD +196 -0
- agent_cli-0.70.5.dist-info/WHEEL +4 -0
- agent_cli-0.70.5.dist-info/entry_points.txt +4 -0
- agent_cli-0.70.5.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,856 @@
|
|
|
1
|
+
"""Git worktree operations for the dev module."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import contextlib
|
|
6
|
+
import os
|
|
7
|
+
import re
|
|
8
|
+
import shutil
|
|
9
|
+
import subprocess
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import TYPE_CHECKING
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from collections.abc import Callable
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _run_git(
|
|
19
|
+
*args: str,
|
|
20
|
+
cwd: Path | None = None,
|
|
21
|
+
check: bool = True,
|
|
22
|
+
capture_output: bool = True,
|
|
23
|
+
allow_file_protocol: bool = False,
|
|
24
|
+
) -> subprocess.CompletedProcess[str]:
|
|
25
|
+
"""Run a git command and return the result."""
|
|
26
|
+
cmd = ["git"]
|
|
27
|
+
# Allow file:// protocol for local clones (disabled by default in newer git)
|
|
28
|
+
if allow_file_protocol:
|
|
29
|
+
cmd.extend(["-c", "protocol.file.allow=always"])
|
|
30
|
+
cmd.extend(args)
|
|
31
|
+
# Suppress SSH "Permanently added host" warnings by setting LogLevel=ERROR
|
|
32
|
+
env = os.environ.copy()
|
|
33
|
+
env.setdefault("GIT_SSH_COMMAND", "ssh -o LogLevel=ERROR")
|
|
34
|
+
return subprocess.run(
|
|
35
|
+
cmd,
|
|
36
|
+
cwd=cwd,
|
|
37
|
+
check=check,
|
|
38
|
+
capture_output=capture_output,
|
|
39
|
+
text=True,
|
|
40
|
+
env=env,
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def git_available() -> bool:
|
|
45
|
+
"""Check if git is available."""
|
|
46
|
+
return shutil.which("git") is not None
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def is_git_repo(path: Path | None = None) -> bool:
|
|
50
|
+
"""Check if the given path is inside a git repository."""
|
|
51
|
+
try:
|
|
52
|
+
result = _run_git("rev-parse", "--git-dir", cwd=path, check=False)
|
|
53
|
+
return result.returncode == 0
|
|
54
|
+
except Exception:
|
|
55
|
+
return False
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def has_origin_remote(path: Path | None = None) -> bool:
|
|
59
|
+
"""Check if the repository has an 'origin' remote configured."""
|
|
60
|
+
try:
|
|
61
|
+
result = _run_git("remote", "get-url", "origin", cwd=path, check=False)
|
|
62
|
+
return result.returncode == 0
|
|
63
|
+
except Exception:
|
|
64
|
+
return False
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def get_repo_root(path: Path | None = None) -> Path | None:
|
|
68
|
+
"""Get the root directory of the git repository."""
|
|
69
|
+
try:
|
|
70
|
+
result = _run_git("rev-parse", "--show-toplevel", cwd=path)
|
|
71
|
+
return Path(result.stdout.strip())
|
|
72
|
+
except subprocess.CalledProcessError:
|
|
73
|
+
return None
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def get_common_dir(path: Path | None = None) -> Path | None:
|
|
77
|
+
"""Get the common git directory (shared across worktrees)."""
|
|
78
|
+
try:
|
|
79
|
+
result = _run_git("rev-parse", "--git-common-dir", cwd=path)
|
|
80
|
+
common_dir = result.stdout.strip()
|
|
81
|
+
if common_dir == ".git":
|
|
82
|
+
# In main repo, resolve relative to toplevel
|
|
83
|
+
repo_root = get_repo_root(path)
|
|
84
|
+
return repo_root / ".git" if repo_root else None
|
|
85
|
+
return Path(common_dir)
|
|
86
|
+
except subprocess.CalledProcessError:
|
|
87
|
+
return None
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def get_main_repo_root(path: Path | None = None) -> Path | None:
|
|
91
|
+
"""Get the main repository root (even when in a worktree).
|
|
92
|
+
|
|
93
|
+
Handles regular repos, worktrees, and submodules correctly.
|
|
94
|
+
"""
|
|
95
|
+
common_dir = get_common_dir(path)
|
|
96
|
+
if common_dir is None:
|
|
97
|
+
return None
|
|
98
|
+
# common_dir is /path/to/repo/.git, so parent is repo root
|
|
99
|
+
if common_dir.name == ".git":
|
|
100
|
+
return common_dir.parent
|
|
101
|
+
# Check if we're in a submodule (common_dir is inside .git/modules/)
|
|
102
|
+
# e.g., /path/to/parent/.git/modules/submodule-name
|
|
103
|
+
parts = common_dir.parts
|
|
104
|
+
for i, part in enumerate(parts[:-1]):
|
|
105
|
+
if part == ".git" and parts[i + 1] == "modules":
|
|
106
|
+
# For submodules, use --show-toplevel to get the submodule's working directory
|
|
107
|
+
return get_repo_root(path)
|
|
108
|
+
# For bare repos or unusual setups, try to go up from common_dir
|
|
109
|
+
return common_dir.parent
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def sanitize_branch_name(branch: str) -> str:
|
|
113
|
+
"""Sanitize a branch name for use as a directory name.
|
|
114
|
+
|
|
115
|
+
Converts slashes, spaces, and other problematic characters to hyphens.
|
|
116
|
+
"""
|
|
117
|
+
# Replace problematic characters with hyphens
|
|
118
|
+
sanitized = re.sub(r'[\/\\ :*?"<>|#]', "-", branch)
|
|
119
|
+
# Remove leading/trailing hyphens
|
|
120
|
+
return sanitized.strip("-")
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def get_default_branch(path: Path | None = None) -> str:
|
|
124
|
+
"""Get the default branch name (main or master)."""
|
|
125
|
+
try:
|
|
126
|
+
# Try to get from origin/HEAD
|
|
127
|
+
result = _run_git(
|
|
128
|
+
"symbolic-ref",
|
|
129
|
+
"--quiet",
|
|
130
|
+
"refs/remotes/origin/HEAD",
|
|
131
|
+
cwd=path,
|
|
132
|
+
check=False,
|
|
133
|
+
)
|
|
134
|
+
if result.returncode == 0:
|
|
135
|
+
# refs/remotes/origin/main -> main
|
|
136
|
+
return result.stdout.strip().replace("refs/remotes/origin/", "")
|
|
137
|
+
except Exception: # noqa: S110
|
|
138
|
+
pass
|
|
139
|
+
|
|
140
|
+
# Try common branch names
|
|
141
|
+
for branch in ["main", "master"]:
|
|
142
|
+
try:
|
|
143
|
+
result = _run_git(
|
|
144
|
+
"show-ref",
|
|
145
|
+
"--verify",
|
|
146
|
+
"--quiet",
|
|
147
|
+
f"refs/remotes/origin/{branch}",
|
|
148
|
+
cwd=path,
|
|
149
|
+
check=False,
|
|
150
|
+
)
|
|
151
|
+
if result.returncode == 0:
|
|
152
|
+
return branch
|
|
153
|
+
except Exception: # noqa: S110
|
|
154
|
+
pass
|
|
155
|
+
|
|
156
|
+
return "main" # Default fallback
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def get_current_branch(path: Path | None = None) -> str | None:
|
|
160
|
+
"""Get the current branch name."""
|
|
161
|
+
try:
|
|
162
|
+
result = _run_git("branch", "--show-current", cwd=path, check=False)
|
|
163
|
+
if result.returncode == 0 and result.stdout.strip():
|
|
164
|
+
return result.stdout.strip()
|
|
165
|
+
# Fallback for older git or detached HEAD
|
|
166
|
+
result = _run_git("rev-parse", "--abbrev-ref", "HEAD", cwd=path, check=False)
|
|
167
|
+
branch = result.stdout.strip()
|
|
168
|
+
return None if branch == "HEAD" else branch
|
|
169
|
+
except Exception:
|
|
170
|
+
return None
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
@dataclass
|
|
174
|
+
class WorktreeInfo:
|
|
175
|
+
"""Information about a git worktree."""
|
|
176
|
+
|
|
177
|
+
path: Path
|
|
178
|
+
branch: str | None
|
|
179
|
+
commit: str | None
|
|
180
|
+
is_main: bool
|
|
181
|
+
is_detached: bool
|
|
182
|
+
is_locked: bool
|
|
183
|
+
is_prunable: bool
|
|
184
|
+
|
|
185
|
+
@property
|
|
186
|
+
def name(self) -> str:
|
|
187
|
+
"""Get the worktree directory name."""
|
|
188
|
+
return self.path.name
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def list_worktrees(repo_path: Path | None = None) -> list[WorktreeInfo]:
|
|
192
|
+
"""List all worktrees for the repository."""
|
|
193
|
+
worktrees: list[WorktreeInfo] = []
|
|
194
|
+
|
|
195
|
+
try:
|
|
196
|
+
result = _run_git("worktree", "list", "--porcelain", cwd=repo_path)
|
|
197
|
+
except subprocess.CalledProcessError:
|
|
198
|
+
return worktrees
|
|
199
|
+
|
|
200
|
+
# Parse porcelain output
|
|
201
|
+
current_wt: dict[str, str | bool] = {}
|
|
202
|
+
|
|
203
|
+
for line in result.stdout.splitlines():
|
|
204
|
+
if not line:
|
|
205
|
+
# End of worktree entry
|
|
206
|
+
if "worktree" in current_wt:
|
|
207
|
+
wt_path = Path(str(current_wt["worktree"]))
|
|
208
|
+
worktrees.append(
|
|
209
|
+
WorktreeInfo(
|
|
210
|
+
path=wt_path,
|
|
211
|
+
branch=str(current_wt.get("branch", "")).replace(
|
|
212
|
+
"refs/heads/",
|
|
213
|
+
"",
|
|
214
|
+
)
|
|
215
|
+
or None,
|
|
216
|
+
commit=str(current_wt.get("HEAD", "")) or None,
|
|
217
|
+
is_main=len(worktrees) == 0, # First worktree is main
|
|
218
|
+
is_detached=current_wt.get("detached", False) is True,
|
|
219
|
+
is_locked=current_wt.get("locked", False) is True,
|
|
220
|
+
is_prunable=current_wt.get("prunable", False) is True,
|
|
221
|
+
),
|
|
222
|
+
)
|
|
223
|
+
current_wt = {}
|
|
224
|
+
continue
|
|
225
|
+
|
|
226
|
+
if line.startswith("worktree "):
|
|
227
|
+
current_wt["worktree"] = line[9:]
|
|
228
|
+
elif line.startswith("HEAD "):
|
|
229
|
+
current_wt["HEAD"] = line[5:]
|
|
230
|
+
elif line.startswith("branch "):
|
|
231
|
+
current_wt["branch"] = line[7:]
|
|
232
|
+
elif line == "detached":
|
|
233
|
+
current_wt["detached"] = True
|
|
234
|
+
elif line.startswith("locked"):
|
|
235
|
+
current_wt["locked"] = True
|
|
236
|
+
elif line.startswith("prunable"):
|
|
237
|
+
current_wt["prunable"] = True
|
|
238
|
+
|
|
239
|
+
# Handle last entry if no trailing newline
|
|
240
|
+
if "worktree" in current_wt:
|
|
241
|
+
wt_path = Path(str(current_wt["worktree"]))
|
|
242
|
+
worktrees.append(
|
|
243
|
+
WorktreeInfo(
|
|
244
|
+
path=wt_path,
|
|
245
|
+
branch=str(current_wt.get("branch", "")).replace("refs/heads/", "") or None,
|
|
246
|
+
commit=str(current_wt.get("HEAD", "")) or None,
|
|
247
|
+
is_main=len(worktrees) == 0,
|
|
248
|
+
is_detached=current_wt.get("detached", False) is True,
|
|
249
|
+
is_locked=current_wt.get("locked", False) is True,
|
|
250
|
+
is_prunable=current_wt.get("prunable", False) is True,
|
|
251
|
+
),
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
return worktrees
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def resolve_worktree_base_dir(repo_root: Path) -> Path:
|
|
258
|
+
"""Resolve the base directory for worktrees.
|
|
259
|
+
|
|
260
|
+
Default: <repo>-worktrees next to the repo.
|
|
261
|
+
Can be configured via GTR_WORKTREES_DIR environment variable.
|
|
262
|
+
"""
|
|
263
|
+
env_dir = os.environ.get("AGENT_SPACE_DIR") or os.environ.get("GTR_WORKTREES_DIR")
|
|
264
|
+
if env_dir:
|
|
265
|
+
base_dir = Path(env_dir).expanduser()
|
|
266
|
+
if not base_dir.is_absolute():
|
|
267
|
+
base_dir = repo_root / base_dir
|
|
268
|
+
return base_dir
|
|
269
|
+
|
|
270
|
+
# Default: sibling directory named <repo>-worktrees
|
|
271
|
+
return repo_root.parent / f"{repo_root.name}-worktrees"
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def find_worktree_by_name(
|
|
275
|
+
name: str,
|
|
276
|
+
repo_path: Path | None = None,
|
|
277
|
+
) -> WorktreeInfo | None:
|
|
278
|
+
"""Find a worktree by branch name or directory name."""
|
|
279
|
+
worktrees = list_worktrees(repo_path)
|
|
280
|
+
sanitized = sanitize_branch_name(name)
|
|
281
|
+
|
|
282
|
+
for wt in worktrees:
|
|
283
|
+
# Match by branch name
|
|
284
|
+
if wt.branch == name:
|
|
285
|
+
return wt
|
|
286
|
+
# Match by directory name
|
|
287
|
+
if wt.path.name in {sanitized, name}:
|
|
288
|
+
return wt
|
|
289
|
+
# Match by sanitized branch name
|
|
290
|
+
if wt.branch and sanitize_branch_name(wt.branch) == sanitized:
|
|
291
|
+
return wt
|
|
292
|
+
|
|
293
|
+
return None
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
@dataclass
|
|
297
|
+
class CreateWorktreeResult:
|
|
298
|
+
"""Result of creating a worktree."""
|
|
299
|
+
|
|
300
|
+
success: bool
|
|
301
|
+
path: Path | None
|
|
302
|
+
branch: str
|
|
303
|
+
error: str | None = None
|
|
304
|
+
warning: str | None = None
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def _check_branch_exists(branch_name: str, repo_root: Path) -> tuple[bool, bool]:
|
|
308
|
+
"""Check if a branch exists remotely and/or locally.
|
|
309
|
+
|
|
310
|
+
Returns:
|
|
311
|
+
Tuple of (remote_exists, local_exists)
|
|
312
|
+
|
|
313
|
+
"""
|
|
314
|
+
remote_exists = False
|
|
315
|
+
local_exists = False
|
|
316
|
+
|
|
317
|
+
try:
|
|
318
|
+
result = _run_git(
|
|
319
|
+
"show-ref",
|
|
320
|
+
"--verify",
|
|
321
|
+
"--quiet",
|
|
322
|
+
f"refs/remotes/origin/{branch_name}",
|
|
323
|
+
cwd=repo_root,
|
|
324
|
+
check=False,
|
|
325
|
+
)
|
|
326
|
+
remote_exists = result.returncode == 0
|
|
327
|
+
except Exception: # noqa: S110
|
|
328
|
+
pass
|
|
329
|
+
|
|
330
|
+
try:
|
|
331
|
+
result = _run_git(
|
|
332
|
+
"show-ref",
|
|
333
|
+
"--verify",
|
|
334
|
+
"--quiet",
|
|
335
|
+
f"refs/heads/{branch_name}",
|
|
336
|
+
cwd=repo_root,
|
|
337
|
+
check=False,
|
|
338
|
+
)
|
|
339
|
+
local_exists = result.returncode == 0
|
|
340
|
+
except Exception: # noqa: S110
|
|
341
|
+
pass
|
|
342
|
+
|
|
343
|
+
return remote_exists, local_exists
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def _parse_git_config_regexp(output: str, prefix: str, suffix: str) -> list[tuple[str, str]]:
|
|
347
|
+
"""Parse git config --get-regexp output into (extracted_name, value) pairs."""
|
|
348
|
+
results: list[tuple[str, str]] = []
|
|
349
|
+
for line in output.strip().split("\n"):
|
|
350
|
+
if not line or " " not in line:
|
|
351
|
+
continue
|
|
352
|
+
key, value = line.split(" ", 1)
|
|
353
|
+
name = key.removeprefix(prefix).removesuffix(suffix)
|
|
354
|
+
results.append((name, value))
|
|
355
|
+
return results
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def _init_submodules_recursive(
|
|
359
|
+
repo_path: Path,
|
|
360
|
+
ref_modules_dir: Path | None,
|
|
361
|
+
on_log: Callable[[str], None] | None,
|
|
362
|
+
capture_output: bool,
|
|
363
|
+
depth: int = 0,
|
|
364
|
+
) -> None:
|
|
365
|
+
"""Recursively initialize submodules, using local clones when available."""
|
|
366
|
+
if not (repo_path / ".gitmodules").exists():
|
|
367
|
+
return
|
|
368
|
+
|
|
369
|
+
# Register submodules in .git/config
|
|
370
|
+
_run_git("submodule", "init", cwd=repo_path, check=False, capture_output=capture_output)
|
|
371
|
+
|
|
372
|
+
# Get submodule names and URLs from config
|
|
373
|
+
result = _run_git(
|
|
374
|
+
"config",
|
|
375
|
+
"--local",
|
|
376
|
+
"--get-regexp",
|
|
377
|
+
r"^submodule\..*\.url$",
|
|
378
|
+
cwd=repo_path,
|
|
379
|
+
check=False,
|
|
380
|
+
)
|
|
381
|
+
submodule_urls = _parse_git_config_regexp(result.stdout, "submodule.", ".url")
|
|
382
|
+
if not submodule_urls:
|
|
383
|
+
return
|
|
384
|
+
|
|
385
|
+
# Get submodule paths from .gitmodules (name != path in some cases)
|
|
386
|
+
# This is the canonical source - only submodules in .gitmodules should be initialized
|
|
387
|
+
result = _run_git(
|
|
388
|
+
"config",
|
|
389
|
+
"--file",
|
|
390
|
+
".gitmodules",
|
|
391
|
+
"--get-regexp",
|
|
392
|
+
r"^submodule\..*\.path$",
|
|
393
|
+
cwd=repo_path,
|
|
394
|
+
check=False,
|
|
395
|
+
)
|
|
396
|
+
name_to_path = dict(_parse_git_config_regexp(result.stdout, "submodule.", ".path"))
|
|
397
|
+
|
|
398
|
+
# Filter to only submodules that exist in .gitmodules (not stale config entries)
|
|
399
|
+
submodule_urls = [(name, url) for name, url in submodule_urls if name in name_to_path]
|
|
400
|
+
if not submodule_urls:
|
|
401
|
+
return
|
|
402
|
+
|
|
403
|
+
# Override URLs to local paths where available, track for restoration
|
|
404
|
+
overrides: list[tuple[str, str]] = [] # (name, original_url)
|
|
405
|
+
for name, original_url in submodule_urls:
|
|
406
|
+
if ref_modules_dir is None:
|
|
407
|
+
continue
|
|
408
|
+
local_module = ref_modules_dir / name
|
|
409
|
+
if not local_module.exists():
|
|
410
|
+
continue
|
|
411
|
+
overrides.append((name, original_url))
|
|
412
|
+
_run_git("config", f"submodule.{name}.url", str(local_module), cwd=repo_path, check=False)
|
|
413
|
+
if on_log:
|
|
414
|
+
on_log(f"{' ' * depth}Using local clone for {name}")
|
|
415
|
+
|
|
416
|
+
# Clone submodules (NOT recursive - we'll handle children ourselves)
|
|
417
|
+
_run_git(
|
|
418
|
+
"submodule",
|
|
419
|
+
"update",
|
|
420
|
+
cwd=repo_path,
|
|
421
|
+
check=False,
|
|
422
|
+
capture_output=capture_output,
|
|
423
|
+
allow_file_protocol=bool(overrides),
|
|
424
|
+
)
|
|
425
|
+
|
|
426
|
+
# Restore original URLs for future remote fetches
|
|
427
|
+
for name, original_url in overrides:
|
|
428
|
+
_run_git("config", f"submodule.{name}.url", original_url, cwd=repo_path, check=False)
|
|
429
|
+
|
|
430
|
+
# Recursively initialize nested submodules
|
|
431
|
+
for name, _original_url in submodule_urls:
|
|
432
|
+
child_repo = repo_path / name_to_path.get(name, name)
|
|
433
|
+
if not child_repo.exists():
|
|
434
|
+
continue
|
|
435
|
+
child_ref = ref_modules_dir / name / "modules" if ref_modules_dir else None
|
|
436
|
+
if child_ref and not child_ref.exists():
|
|
437
|
+
child_ref = None
|
|
438
|
+
_init_submodules_recursive(child_repo, child_ref, on_log, capture_output, depth + 1)
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
def _init_submodules(
|
|
442
|
+
worktree_path: Path,
|
|
443
|
+
*,
|
|
444
|
+
reference_repo: Path | None = None,
|
|
445
|
+
on_log: Callable[[str], None] | None = None,
|
|
446
|
+
capture_output: bool = True,
|
|
447
|
+
) -> None:
|
|
448
|
+
"""Initialize git submodules in a worktree, using local clones when available."""
|
|
449
|
+
if not (worktree_path / ".gitmodules").exists():
|
|
450
|
+
return
|
|
451
|
+
|
|
452
|
+
if on_log:
|
|
453
|
+
on_log("Initializing submodules...")
|
|
454
|
+
|
|
455
|
+
# Get reference repo's git dir for local submodule clones
|
|
456
|
+
ref_modules_dir: Path | None = None
|
|
457
|
+
if reference_repo is not None:
|
|
458
|
+
result = _run_git("rev-parse", "--git-dir", cwd=reference_repo, check=False)
|
|
459
|
+
if result.returncode == 0:
|
|
460
|
+
ref_git_dir = Path(result.stdout.strip())
|
|
461
|
+
if not ref_git_dir.is_absolute():
|
|
462
|
+
ref_git_dir = reference_repo / ref_git_dir
|
|
463
|
+
ref_modules_dir = ref_git_dir / "modules"
|
|
464
|
+
|
|
465
|
+
_init_submodules_recursive(
|
|
466
|
+
worktree_path,
|
|
467
|
+
ref_modules_dir,
|
|
468
|
+
on_log,
|
|
469
|
+
capture_output,
|
|
470
|
+
)
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
def _pull_lfs(
|
|
474
|
+
worktree_path: Path,
|
|
475
|
+
*,
|
|
476
|
+
on_log: Callable[[str], None] | None = None,
|
|
477
|
+
capture_output: bool = True,
|
|
478
|
+
) -> None:
|
|
479
|
+
"""Pull Git LFS files in a worktree if LFS is used.
|
|
480
|
+
|
|
481
|
+
Evidence: https://git-lfs.com/ - `git lfs pull` fetches LFS objects.
|
|
482
|
+
This is a no-op if LFS is not used or files are already present.
|
|
483
|
+
"""
|
|
484
|
+
# Check if .gitattributes contains LFS filters
|
|
485
|
+
gitattributes = worktree_path / ".gitattributes"
|
|
486
|
+
if not gitattributes.exists():
|
|
487
|
+
return
|
|
488
|
+
|
|
489
|
+
if "filter=lfs" not in gitattributes.read_text():
|
|
490
|
+
return
|
|
491
|
+
|
|
492
|
+
# Check if git-lfs is installed
|
|
493
|
+
if not shutil.which("git-lfs"):
|
|
494
|
+
return
|
|
495
|
+
|
|
496
|
+
if on_log:
|
|
497
|
+
on_log("Pulling Git LFS files...")
|
|
498
|
+
|
|
499
|
+
_run_git("lfs", "pull", cwd=worktree_path, check=False, capture_output=capture_output)
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
def _add_worktree(
|
|
503
|
+
branch_name: str,
|
|
504
|
+
worktree_path: Path,
|
|
505
|
+
repo_root: Path,
|
|
506
|
+
from_ref: str,
|
|
507
|
+
*,
|
|
508
|
+
remote_exists: bool,
|
|
509
|
+
local_exists: bool,
|
|
510
|
+
force: bool,
|
|
511
|
+
on_log: Callable[[str], None] | None,
|
|
512
|
+
capture_output: bool = True,
|
|
513
|
+
) -> None:
|
|
514
|
+
"""Add a git worktree, handling different branch scenarios."""
|
|
515
|
+
force_flag = ["--force"] if force else []
|
|
516
|
+
|
|
517
|
+
if remote_exists and not local_exists:
|
|
518
|
+
# Remote branch exists, create tracking branch
|
|
519
|
+
if on_log:
|
|
520
|
+
on_log(f"Running: git branch --track {branch_name} origin/{branch_name}")
|
|
521
|
+
_run_git(
|
|
522
|
+
"branch",
|
|
523
|
+
"--track",
|
|
524
|
+
branch_name,
|
|
525
|
+
f"origin/{branch_name}",
|
|
526
|
+
cwd=repo_root,
|
|
527
|
+
check=False,
|
|
528
|
+
capture_output=capture_output,
|
|
529
|
+
)
|
|
530
|
+
if on_log:
|
|
531
|
+
on_log(f"Running: git worktree add {worktree_path} {branch_name}")
|
|
532
|
+
_run_git(
|
|
533
|
+
"worktree",
|
|
534
|
+
"add",
|
|
535
|
+
*force_flag,
|
|
536
|
+
str(worktree_path),
|
|
537
|
+
branch_name,
|
|
538
|
+
cwd=repo_root,
|
|
539
|
+
capture_output=capture_output,
|
|
540
|
+
)
|
|
541
|
+
elif local_exists:
|
|
542
|
+
# Local branch exists
|
|
543
|
+
if on_log:
|
|
544
|
+
on_log(f"Running: git worktree add {worktree_path} {branch_name}")
|
|
545
|
+
_run_git(
|
|
546
|
+
"worktree",
|
|
547
|
+
"add",
|
|
548
|
+
*force_flag,
|
|
549
|
+
str(worktree_path),
|
|
550
|
+
branch_name,
|
|
551
|
+
cwd=repo_root,
|
|
552
|
+
capture_output=capture_output,
|
|
553
|
+
)
|
|
554
|
+
else:
|
|
555
|
+
# Create new branch from ref
|
|
556
|
+
if on_log:
|
|
557
|
+
on_log(f"Running: git worktree add -b {branch_name} {worktree_path} {from_ref}")
|
|
558
|
+
_run_git(
|
|
559
|
+
"worktree",
|
|
560
|
+
"add",
|
|
561
|
+
*force_flag,
|
|
562
|
+
str(worktree_path),
|
|
563
|
+
"-b",
|
|
564
|
+
branch_name,
|
|
565
|
+
from_ref,
|
|
566
|
+
cwd=repo_root,
|
|
567
|
+
capture_output=capture_output,
|
|
568
|
+
)
|
|
569
|
+
|
|
570
|
+
|
|
571
|
+
def create_worktree(
|
|
572
|
+
branch_name: str,
|
|
573
|
+
*,
|
|
574
|
+
repo_path: Path | None = None,
|
|
575
|
+
from_ref: str | None = None,
|
|
576
|
+
base_dir: Path | None = None,
|
|
577
|
+
prefix: str = "",
|
|
578
|
+
force: bool = False,
|
|
579
|
+
fetch: bool = True,
|
|
580
|
+
on_log: Callable[[str], None] | None = None,
|
|
581
|
+
capture_output: bool = True,
|
|
582
|
+
) -> CreateWorktreeResult:
|
|
583
|
+
"""Create a new git worktree.
|
|
584
|
+
|
|
585
|
+
Args:
|
|
586
|
+
branch_name: The branch name for the worktree
|
|
587
|
+
repo_path: Path to the repository (default: current directory)
|
|
588
|
+
from_ref: Reference to create the branch from (default: default branch)
|
|
589
|
+
base_dir: Base directory for worktrees (default: auto-resolved)
|
|
590
|
+
prefix: Prefix for the worktree directory name
|
|
591
|
+
force: Allow same branch in multiple worktrees
|
|
592
|
+
fetch: Fetch from origin before creating
|
|
593
|
+
on_log: Optional callback for logging status messages
|
|
594
|
+
capture_output: Whether to capture command output (False to stream)
|
|
595
|
+
|
|
596
|
+
Returns:
|
|
597
|
+
CreateWorktreeResult with success status and path or error
|
|
598
|
+
|
|
599
|
+
"""
|
|
600
|
+
repo_root = get_main_repo_root(repo_path)
|
|
601
|
+
if repo_root is None:
|
|
602
|
+
return CreateWorktreeResult(
|
|
603
|
+
success=False,
|
|
604
|
+
path=None,
|
|
605
|
+
branch=branch_name,
|
|
606
|
+
error="Not in a git repository",
|
|
607
|
+
)
|
|
608
|
+
|
|
609
|
+
if base_dir is None:
|
|
610
|
+
base_dir = resolve_worktree_base_dir(repo_root)
|
|
611
|
+
|
|
612
|
+
sanitized_name = sanitize_branch_name(branch_name)
|
|
613
|
+
worktree_path = base_dir / f"{prefix}{sanitized_name}"
|
|
614
|
+
|
|
615
|
+
# Check if worktree already exists
|
|
616
|
+
if worktree_path.exists():
|
|
617
|
+
return CreateWorktreeResult(
|
|
618
|
+
success=False,
|
|
619
|
+
path=worktree_path,
|
|
620
|
+
branch=branch_name,
|
|
621
|
+
error=f"Worktree already exists at {worktree_path}",
|
|
622
|
+
)
|
|
623
|
+
|
|
624
|
+
# Create base directory if needed
|
|
625
|
+
base_dir.mkdir(parents=True, exist_ok=True)
|
|
626
|
+
|
|
627
|
+
# Check if origin remote exists
|
|
628
|
+
origin_exists = has_origin_remote(repo_root)
|
|
629
|
+
|
|
630
|
+
# Fetch latest refs (only if origin exists)
|
|
631
|
+
if fetch and origin_exists:
|
|
632
|
+
if on_log:
|
|
633
|
+
on_log("Running: git fetch origin")
|
|
634
|
+
_run_git("fetch", "origin", cwd=repo_root, check=False, capture_output=capture_output)
|
|
635
|
+
|
|
636
|
+
# Track if user explicitly provided --from (for warning generation)
|
|
637
|
+
from_ref_explicit = from_ref is not None
|
|
638
|
+
|
|
639
|
+
# Determine the reference to create from
|
|
640
|
+
# Use origin/{branch} if origin exists, otherwise use local branch
|
|
641
|
+
if from_ref is None:
|
|
642
|
+
default_branch = get_default_branch(repo_root)
|
|
643
|
+
from_ref = f"origin/{default_branch}" if origin_exists else default_branch
|
|
644
|
+
|
|
645
|
+
# Check if branch exists remotely or locally
|
|
646
|
+
remote_exists, local_exists = _check_branch_exists(branch_name, repo_root)
|
|
647
|
+
|
|
648
|
+
# Generate warning if --from was specified but will be ignored
|
|
649
|
+
warning: str | None = None
|
|
650
|
+
if from_ref_explicit and (local_exists or remote_exists):
|
|
651
|
+
warning = (
|
|
652
|
+
f"Branch '{branch_name}' already exists. "
|
|
653
|
+
f"Using existing branch instead of creating from '{from_ref}'."
|
|
654
|
+
)
|
|
655
|
+
|
|
656
|
+
try:
|
|
657
|
+
_add_worktree(
|
|
658
|
+
branch_name,
|
|
659
|
+
worktree_path,
|
|
660
|
+
repo_root,
|
|
661
|
+
from_ref,
|
|
662
|
+
remote_exists=remote_exists,
|
|
663
|
+
local_exists=local_exists,
|
|
664
|
+
force=force,
|
|
665
|
+
on_log=on_log,
|
|
666
|
+
capture_output=capture_output,
|
|
667
|
+
)
|
|
668
|
+
|
|
669
|
+
# Initialize submodules in the new worktree, using main repo as reference
|
|
670
|
+
# to avoid re-fetching objects that already exist locally
|
|
671
|
+
_init_submodules(
|
|
672
|
+
worktree_path,
|
|
673
|
+
reference_repo=repo_root,
|
|
674
|
+
on_log=on_log,
|
|
675
|
+
capture_output=capture_output,
|
|
676
|
+
)
|
|
677
|
+
|
|
678
|
+
# Pull Git LFS files if the repo uses LFS
|
|
679
|
+
_pull_lfs(
|
|
680
|
+
worktree_path,
|
|
681
|
+
on_log=on_log,
|
|
682
|
+
capture_output=capture_output,
|
|
683
|
+
)
|
|
684
|
+
|
|
685
|
+
return CreateWorktreeResult(
|
|
686
|
+
success=True,
|
|
687
|
+
path=worktree_path,
|
|
688
|
+
branch=branch_name,
|
|
689
|
+
warning=warning,
|
|
690
|
+
)
|
|
691
|
+
|
|
692
|
+
except subprocess.CalledProcessError as e:
|
|
693
|
+
return CreateWorktreeResult(
|
|
694
|
+
success=False,
|
|
695
|
+
path=worktree_path,
|
|
696
|
+
branch=branch_name,
|
|
697
|
+
error=e.stderr.strip() if e.stderr else str(e),
|
|
698
|
+
)
|
|
699
|
+
|
|
700
|
+
|
|
701
|
+
def remove_worktree(
|
|
702
|
+
worktree_path: Path,
|
|
703
|
+
*,
|
|
704
|
+
force: bool = False,
|
|
705
|
+
delete_branch: bool = False,
|
|
706
|
+
repo_path: Path | None = None,
|
|
707
|
+
) -> tuple[bool, str | None]:
|
|
708
|
+
"""Remove a git worktree.
|
|
709
|
+
|
|
710
|
+
Args:
|
|
711
|
+
worktree_path: Path to the worktree to remove
|
|
712
|
+
force: Force removal even with uncommitted changes
|
|
713
|
+
delete_branch: Also delete the branch
|
|
714
|
+
repo_path: Path to the main repository
|
|
715
|
+
|
|
716
|
+
Returns:
|
|
717
|
+
Tuple of (success, error_message)
|
|
718
|
+
|
|
719
|
+
"""
|
|
720
|
+
if not worktree_path.exists():
|
|
721
|
+
return False, f"Worktree not found at {worktree_path}"
|
|
722
|
+
|
|
723
|
+
repo_root = get_main_repo_root(repo_path)
|
|
724
|
+
if repo_root is None:
|
|
725
|
+
return False, "Not in a git repository"
|
|
726
|
+
|
|
727
|
+
# Get branch name before removing
|
|
728
|
+
branch_name = get_current_branch(worktree_path)
|
|
729
|
+
|
|
730
|
+
force_flag = ["--force"] if force else []
|
|
731
|
+
|
|
732
|
+
try:
|
|
733
|
+
_run_git(
|
|
734
|
+
"worktree",
|
|
735
|
+
"remove",
|
|
736
|
+
*force_flag,
|
|
737
|
+
str(worktree_path),
|
|
738
|
+
cwd=repo_root,
|
|
739
|
+
)
|
|
740
|
+
except subprocess.CalledProcessError as e:
|
|
741
|
+
return False, e.stderr.strip() if e.stderr else str(e)
|
|
742
|
+
|
|
743
|
+
# Delete branch if requested
|
|
744
|
+
if delete_branch and branch_name:
|
|
745
|
+
with contextlib.suppress(Exception):
|
|
746
|
+
_run_git(
|
|
747
|
+
"branch",
|
|
748
|
+
"-D" if force else "-d",
|
|
749
|
+
branch_name,
|
|
750
|
+
cwd=repo_root,
|
|
751
|
+
check=False,
|
|
752
|
+
)
|
|
753
|
+
|
|
754
|
+
return True, None
|
|
755
|
+
|
|
756
|
+
|
|
757
|
+
def prune_worktrees(repo_path: Path | None = None) -> None:
|
|
758
|
+
"""Prune stale worktree references."""
|
|
759
|
+
repo_root = get_main_repo_root(repo_path)
|
|
760
|
+
if repo_root:
|
|
761
|
+
_run_git("worktree", "prune", cwd=repo_root, check=False)
|
|
762
|
+
|
|
763
|
+
|
|
764
|
+
@dataclass
|
|
765
|
+
class WorktreeStatus:
|
|
766
|
+
"""Git status information for a worktree."""
|
|
767
|
+
|
|
768
|
+
modified: int # Files modified but not staged
|
|
769
|
+
staged: int # Files staged for commit
|
|
770
|
+
untracked: int # Untracked files
|
|
771
|
+
ahead: int # Commits ahead of upstream
|
|
772
|
+
behind: int # Commits behind upstream
|
|
773
|
+
last_commit_time: str | None # Relative time of last commit (e.g., "2 hours ago")
|
|
774
|
+
last_commit_timestamp: int | None # Unix timestamp of last commit
|
|
775
|
+
|
|
776
|
+
|
|
777
|
+
def _parse_porcelain_status(output: str) -> tuple[int, int, int]:
|
|
778
|
+
"""Parse git status --porcelain output into (modified, staged, untracked) counts."""
|
|
779
|
+
modified = 0
|
|
780
|
+
staged = 0
|
|
781
|
+
untracked = 0
|
|
782
|
+
|
|
783
|
+
for line in output.splitlines():
|
|
784
|
+
if len(line) < 2: # noqa: PLR2004
|
|
785
|
+
continue
|
|
786
|
+
index_status = line[0]
|
|
787
|
+
worktree_status = line[1]
|
|
788
|
+
|
|
789
|
+
# Untracked files
|
|
790
|
+
if index_status == "?" and worktree_status == "?":
|
|
791
|
+
untracked += 1
|
|
792
|
+
else:
|
|
793
|
+
# Staged changes (index has modification)
|
|
794
|
+
if index_status in "MADRCU":
|
|
795
|
+
staged += 1
|
|
796
|
+
# Worktree changes (not staged)
|
|
797
|
+
if worktree_status in "MADRCU":
|
|
798
|
+
modified += 1
|
|
799
|
+
|
|
800
|
+
return modified, staged, untracked
|
|
801
|
+
|
|
802
|
+
|
|
803
|
+
def _parse_ahead_behind(output: str) -> tuple[int, int]:
|
|
804
|
+
"""Parse git rev-list --left-right --count output into (ahead, behind) counts."""
|
|
805
|
+
parts = output.strip().split()
|
|
806
|
+
if len(parts) == 2: # noqa: PLR2004
|
|
807
|
+
return int(parts[1]), int(parts[0]) # ahead, behind
|
|
808
|
+
return 0, 0
|
|
809
|
+
|
|
810
|
+
|
|
811
|
+
def get_worktree_status(worktree_path: Path) -> WorktreeStatus | None:
|
|
812
|
+
"""Get git status information for a worktree.
|
|
813
|
+
|
|
814
|
+
Returns None if the worktree doesn't exist or isn't a valid git repo.
|
|
815
|
+
"""
|
|
816
|
+
if not worktree_path.exists():
|
|
817
|
+
return None
|
|
818
|
+
|
|
819
|
+
# Get porcelain status for file counts
|
|
820
|
+
result = _run_git("status", "--porcelain", cwd=worktree_path, check=False)
|
|
821
|
+
modified, staged, untracked = (
|
|
822
|
+
_parse_porcelain_status(result.stdout) if result.returncode == 0 else (0, 0, 0)
|
|
823
|
+
)
|
|
824
|
+
|
|
825
|
+
# Get ahead/behind counts
|
|
826
|
+
result = _run_git(
|
|
827
|
+
"rev-list",
|
|
828
|
+
"--left-right",
|
|
829
|
+
"--count",
|
|
830
|
+
"@{upstream}...HEAD",
|
|
831
|
+
cwd=worktree_path,
|
|
832
|
+
check=False,
|
|
833
|
+
)
|
|
834
|
+
ahead, behind = _parse_ahead_behind(result.stdout) if result.returncode == 0 else (0, 0)
|
|
835
|
+
|
|
836
|
+
# Get last commit time
|
|
837
|
+
last_commit_time = None
|
|
838
|
+
last_commit_timestamp = None
|
|
839
|
+
|
|
840
|
+
result = _run_git("log", "-1", "--format=%ar", cwd=worktree_path, check=False)
|
|
841
|
+
if result.returncode == 0 and result.stdout.strip():
|
|
842
|
+
last_commit_time = result.stdout.strip()
|
|
843
|
+
|
|
844
|
+
result = _run_git("log", "-1", "--format=%at", cwd=worktree_path, check=False)
|
|
845
|
+
if result.returncode == 0 and result.stdout.strip():
|
|
846
|
+
last_commit_timestamp = int(result.stdout.strip())
|
|
847
|
+
|
|
848
|
+
return WorktreeStatus(
|
|
849
|
+
modified=modified,
|
|
850
|
+
staged=staged,
|
|
851
|
+
untracked=untracked,
|
|
852
|
+
ahead=ahead,
|
|
853
|
+
behind=behind,
|
|
854
|
+
last_commit_time=last_commit_time,
|
|
855
|
+
last_commit_timestamp=last_commit_timestamp,
|
|
856
|
+
)
|