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.
Files changed (196) hide show
  1. agent_cli/__init__.py +5 -0
  2. agent_cli/__main__.py +6 -0
  3. agent_cli/_extras.json +14 -0
  4. agent_cli/_requirements/.gitkeep +0 -0
  5. agent_cli/_requirements/audio.txt +79 -0
  6. agent_cli/_requirements/faster-whisper.txt +215 -0
  7. agent_cli/_requirements/kokoro.txt +425 -0
  8. agent_cli/_requirements/llm.txt +183 -0
  9. agent_cli/_requirements/memory.txt +355 -0
  10. agent_cli/_requirements/mlx-whisper.txt +222 -0
  11. agent_cli/_requirements/piper.txt +176 -0
  12. agent_cli/_requirements/rag.txt +402 -0
  13. agent_cli/_requirements/server.txt +154 -0
  14. agent_cli/_requirements/speed.txt +77 -0
  15. agent_cli/_requirements/vad.txt +155 -0
  16. agent_cli/_requirements/wyoming.txt +71 -0
  17. agent_cli/_tools.py +368 -0
  18. agent_cli/agents/__init__.py +23 -0
  19. agent_cli/agents/_voice_agent_common.py +136 -0
  20. agent_cli/agents/assistant.py +383 -0
  21. agent_cli/agents/autocorrect.py +284 -0
  22. agent_cli/agents/chat.py +496 -0
  23. agent_cli/agents/memory/__init__.py +31 -0
  24. agent_cli/agents/memory/add.py +190 -0
  25. agent_cli/agents/memory/proxy.py +160 -0
  26. agent_cli/agents/rag_proxy.py +128 -0
  27. agent_cli/agents/speak.py +209 -0
  28. agent_cli/agents/transcribe.py +671 -0
  29. agent_cli/agents/transcribe_daemon.py +499 -0
  30. agent_cli/agents/voice_edit.py +291 -0
  31. agent_cli/api.py +22 -0
  32. agent_cli/cli.py +106 -0
  33. agent_cli/config.py +503 -0
  34. agent_cli/config_cmd.py +307 -0
  35. agent_cli/constants.py +27 -0
  36. agent_cli/core/__init__.py +1 -0
  37. agent_cli/core/audio.py +461 -0
  38. agent_cli/core/audio_format.py +299 -0
  39. agent_cli/core/chroma.py +88 -0
  40. agent_cli/core/deps.py +191 -0
  41. agent_cli/core/openai_proxy.py +139 -0
  42. agent_cli/core/process.py +195 -0
  43. agent_cli/core/reranker.py +120 -0
  44. agent_cli/core/sse.py +87 -0
  45. agent_cli/core/transcription_logger.py +70 -0
  46. agent_cli/core/utils.py +526 -0
  47. agent_cli/core/vad.py +175 -0
  48. agent_cli/core/watch.py +65 -0
  49. agent_cli/dev/__init__.py +14 -0
  50. agent_cli/dev/cli.py +1588 -0
  51. agent_cli/dev/coding_agents/__init__.py +19 -0
  52. agent_cli/dev/coding_agents/aider.py +24 -0
  53. agent_cli/dev/coding_agents/base.py +167 -0
  54. agent_cli/dev/coding_agents/claude.py +39 -0
  55. agent_cli/dev/coding_agents/codex.py +24 -0
  56. agent_cli/dev/coding_agents/continue_dev.py +15 -0
  57. agent_cli/dev/coding_agents/copilot.py +24 -0
  58. agent_cli/dev/coding_agents/cursor_agent.py +48 -0
  59. agent_cli/dev/coding_agents/gemini.py +28 -0
  60. agent_cli/dev/coding_agents/opencode.py +15 -0
  61. agent_cli/dev/coding_agents/registry.py +49 -0
  62. agent_cli/dev/editors/__init__.py +19 -0
  63. agent_cli/dev/editors/base.py +89 -0
  64. agent_cli/dev/editors/cursor.py +15 -0
  65. agent_cli/dev/editors/emacs.py +46 -0
  66. agent_cli/dev/editors/jetbrains.py +56 -0
  67. agent_cli/dev/editors/nano.py +31 -0
  68. agent_cli/dev/editors/neovim.py +33 -0
  69. agent_cli/dev/editors/registry.py +59 -0
  70. agent_cli/dev/editors/sublime.py +20 -0
  71. agent_cli/dev/editors/vim.py +42 -0
  72. agent_cli/dev/editors/vscode.py +15 -0
  73. agent_cli/dev/editors/zed.py +20 -0
  74. agent_cli/dev/project.py +568 -0
  75. agent_cli/dev/registry.py +52 -0
  76. agent_cli/dev/skill/SKILL.md +141 -0
  77. agent_cli/dev/skill/examples.md +571 -0
  78. agent_cli/dev/terminals/__init__.py +19 -0
  79. agent_cli/dev/terminals/apple_terminal.py +82 -0
  80. agent_cli/dev/terminals/base.py +56 -0
  81. agent_cli/dev/terminals/gnome.py +51 -0
  82. agent_cli/dev/terminals/iterm2.py +84 -0
  83. agent_cli/dev/terminals/kitty.py +77 -0
  84. agent_cli/dev/terminals/registry.py +48 -0
  85. agent_cli/dev/terminals/tmux.py +58 -0
  86. agent_cli/dev/terminals/warp.py +132 -0
  87. agent_cli/dev/terminals/zellij.py +78 -0
  88. agent_cli/dev/worktree.py +856 -0
  89. agent_cli/docs_gen.py +417 -0
  90. agent_cli/example-config.toml +185 -0
  91. agent_cli/install/__init__.py +5 -0
  92. agent_cli/install/common.py +89 -0
  93. agent_cli/install/extras.py +174 -0
  94. agent_cli/install/hotkeys.py +48 -0
  95. agent_cli/install/services.py +87 -0
  96. agent_cli/memory/__init__.py +7 -0
  97. agent_cli/memory/_files.py +250 -0
  98. agent_cli/memory/_filters.py +63 -0
  99. agent_cli/memory/_git.py +157 -0
  100. agent_cli/memory/_indexer.py +142 -0
  101. agent_cli/memory/_ingest.py +408 -0
  102. agent_cli/memory/_persistence.py +182 -0
  103. agent_cli/memory/_prompt.py +91 -0
  104. agent_cli/memory/_retrieval.py +294 -0
  105. agent_cli/memory/_store.py +169 -0
  106. agent_cli/memory/_streaming.py +44 -0
  107. agent_cli/memory/_tasks.py +48 -0
  108. agent_cli/memory/api.py +113 -0
  109. agent_cli/memory/client.py +272 -0
  110. agent_cli/memory/engine.py +361 -0
  111. agent_cli/memory/entities.py +43 -0
  112. agent_cli/memory/models.py +112 -0
  113. agent_cli/opts.py +433 -0
  114. agent_cli/py.typed +0 -0
  115. agent_cli/rag/__init__.py +3 -0
  116. agent_cli/rag/_indexer.py +67 -0
  117. agent_cli/rag/_indexing.py +226 -0
  118. agent_cli/rag/_prompt.py +30 -0
  119. agent_cli/rag/_retriever.py +156 -0
  120. agent_cli/rag/_store.py +48 -0
  121. agent_cli/rag/_utils.py +218 -0
  122. agent_cli/rag/api.py +175 -0
  123. agent_cli/rag/client.py +299 -0
  124. agent_cli/rag/engine.py +302 -0
  125. agent_cli/rag/models.py +55 -0
  126. agent_cli/scripts/.runtime/.gitkeep +0 -0
  127. agent_cli/scripts/__init__.py +1 -0
  128. agent_cli/scripts/check_plugin_skill_sync.py +50 -0
  129. agent_cli/scripts/linux-hotkeys/README.md +63 -0
  130. agent_cli/scripts/linux-hotkeys/toggle-autocorrect.sh +45 -0
  131. agent_cli/scripts/linux-hotkeys/toggle-transcription.sh +58 -0
  132. agent_cli/scripts/linux-hotkeys/toggle-voice-edit.sh +58 -0
  133. agent_cli/scripts/macos-hotkeys/README.md +45 -0
  134. agent_cli/scripts/macos-hotkeys/skhd-config-example +5 -0
  135. agent_cli/scripts/macos-hotkeys/toggle-autocorrect.sh +12 -0
  136. agent_cli/scripts/macos-hotkeys/toggle-transcription.sh +37 -0
  137. agent_cli/scripts/macos-hotkeys/toggle-voice-edit.sh +37 -0
  138. agent_cli/scripts/nvidia-asr-server/README.md +99 -0
  139. agent_cli/scripts/nvidia-asr-server/pyproject.toml +27 -0
  140. agent_cli/scripts/nvidia-asr-server/server.py +255 -0
  141. agent_cli/scripts/nvidia-asr-server/shell.nix +32 -0
  142. agent_cli/scripts/nvidia-asr-server/uv.lock +4654 -0
  143. agent_cli/scripts/run-openwakeword.sh +11 -0
  144. agent_cli/scripts/run-piper-windows.ps1 +30 -0
  145. agent_cli/scripts/run-piper.sh +24 -0
  146. agent_cli/scripts/run-whisper-linux.sh +40 -0
  147. agent_cli/scripts/run-whisper-macos.sh +6 -0
  148. agent_cli/scripts/run-whisper-windows.ps1 +51 -0
  149. agent_cli/scripts/run-whisper.sh +9 -0
  150. agent_cli/scripts/run_faster_whisper_server.py +136 -0
  151. agent_cli/scripts/setup-linux-hotkeys.sh +72 -0
  152. agent_cli/scripts/setup-linux.sh +108 -0
  153. agent_cli/scripts/setup-macos-hotkeys.sh +61 -0
  154. agent_cli/scripts/setup-macos.sh +76 -0
  155. agent_cli/scripts/setup-windows.ps1 +63 -0
  156. agent_cli/scripts/start-all-services-windows.ps1 +53 -0
  157. agent_cli/scripts/start-all-services.sh +178 -0
  158. agent_cli/scripts/sync_extras.py +138 -0
  159. agent_cli/server/__init__.py +3 -0
  160. agent_cli/server/cli.py +721 -0
  161. agent_cli/server/common.py +222 -0
  162. agent_cli/server/model_manager.py +288 -0
  163. agent_cli/server/model_registry.py +225 -0
  164. agent_cli/server/proxy/__init__.py +3 -0
  165. agent_cli/server/proxy/api.py +444 -0
  166. agent_cli/server/streaming.py +67 -0
  167. agent_cli/server/tts/__init__.py +3 -0
  168. agent_cli/server/tts/api.py +335 -0
  169. agent_cli/server/tts/backends/__init__.py +82 -0
  170. agent_cli/server/tts/backends/base.py +139 -0
  171. agent_cli/server/tts/backends/kokoro.py +403 -0
  172. agent_cli/server/tts/backends/piper.py +253 -0
  173. agent_cli/server/tts/model_manager.py +201 -0
  174. agent_cli/server/tts/model_registry.py +28 -0
  175. agent_cli/server/tts/wyoming_handler.py +249 -0
  176. agent_cli/server/whisper/__init__.py +3 -0
  177. agent_cli/server/whisper/api.py +413 -0
  178. agent_cli/server/whisper/backends/__init__.py +89 -0
  179. agent_cli/server/whisper/backends/base.py +97 -0
  180. agent_cli/server/whisper/backends/faster_whisper.py +225 -0
  181. agent_cli/server/whisper/backends/mlx.py +270 -0
  182. agent_cli/server/whisper/languages.py +116 -0
  183. agent_cli/server/whisper/model_manager.py +157 -0
  184. agent_cli/server/whisper/model_registry.py +28 -0
  185. agent_cli/server/whisper/wyoming_handler.py +203 -0
  186. agent_cli/services/__init__.py +343 -0
  187. agent_cli/services/_wyoming_utils.py +64 -0
  188. agent_cli/services/asr.py +506 -0
  189. agent_cli/services/llm.py +228 -0
  190. agent_cli/services/tts.py +450 -0
  191. agent_cli/services/wake_word.py +142 -0
  192. agent_cli-0.70.5.dist-info/METADATA +2118 -0
  193. agent_cli-0.70.5.dist-info/RECORD +196 -0
  194. agent_cli-0.70.5.dist-info/WHEEL +4 -0
  195. agent_cli-0.70.5.dist-info/entry_points.txt +4 -0
  196. 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
+ )