titan-cli 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (146) hide show
  1. titan_cli/__init__.py +3 -0
  2. titan_cli/__main__.py +4 -0
  3. titan_cli/ai/__init__.py +0 -0
  4. titan_cli/ai/agents/__init__.py +15 -0
  5. titan_cli/ai/agents/base.py +152 -0
  6. titan_cli/ai/client.py +170 -0
  7. titan_cli/ai/constants.py +56 -0
  8. titan_cli/ai/exceptions.py +48 -0
  9. titan_cli/ai/models.py +34 -0
  10. titan_cli/ai/oauth_helper.py +120 -0
  11. titan_cli/ai/providers/__init__.py +9 -0
  12. titan_cli/ai/providers/anthropic.py +117 -0
  13. titan_cli/ai/providers/base.py +75 -0
  14. titan_cli/ai/providers/gemini.py +278 -0
  15. titan_cli/cli.py +59 -0
  16. titan_cli/clients/__init__.py +1 -0
  17. titan_cli/clients/gcloud_client.py +52 -0
  18. titan_cli/core/__init__.py +3 -0
  19. titan_cli/core/config.py +274 -0
  20. titan_cli/core/discovery.py +51 -0
  21. titan_cli/core/errors.py +81 -0
  22. titan_cli/core/models.py +52 -0
  23. titan_cli/core/plugins/available.py +36 -0
  24. titan_cli/core/plugins/models.py +67 -0
  25. titan_cli/core/plugins/plugin_base.py +108 -0
  26. titan_cli/core/plugins/plugin_registry.py +163 -0
  27. titan_cli/core/secrets.py +141 -0
  28. titan_cli/core/workflows/__init__.py +22 -0
  29. titan_cli/core/workflows/models.py +88 -0
  30. titan_cli/core/workflows/project_step_source.py +86 -0
  31. titan_cli/core/workflows/workflow_exceptions.py +17 -0
  32. titan_cli/core/workflows/workflow_filter_service.py +137 -0
  33. titan_cli/core/workflows/workflow_registry.py +419 -0
  34. titan_cli/core/workflows/workflow_sources.py +307 -0
  35. titan_cli/engine/__init__.py +39 -0
  36. titan_cli/engine/builder.py +159 -0
  37. titan_cli/engine/context.py +82 -0
  38. titan_cli/engine/mock_context.py +176 -0
  39. titan_cli/engine/results.py +91 -0
  40. titan_cli/engine/steps/ai_assistant_step.py +185 -0
  41. titan_cli/engine/steps/command_step.py +93 -0
  42. titan_cli/engine/utils/__init__.py +3 -0
  43. titan_cli/engine/utils/venv.py +31 -0
  44. titan_cli/engine/workflow_executor.py +187 -0
  45. titan_cli/external_cli/__init__.py +0 -0
  46. titan_cli/external_cli/configs.py +17 -0
  47. titan_cli/external_cli/launcher.py +65 -0
  48. titan_cli/messages.py +121 -0
  49. titan_cli/ui/tui/__init__.py +205 -0
  50. titan_cli/ui/tui/__previews__/statusbar_preview.py +88 -0
  51. titan_cli/ui/tui/app.py +113 -0
  52. titan_cli/ui/tui/icons.py +70 -0
  53. titan_cli/ui/tui/screens/__init__.py +24 -0
  54. titan_cli/ui/tui/screens/ai_config.py +498 -0
  55. titan_cli/ui/tui/screens/ai_config_wizard.py +882 -0
  56. titan_cli/ui/tui/screens/base.py +110 -0
  57. titan_cli/ui/tui/screens/cli_launcher.py +151 -0
  58. titan_cli/ui/tui/screens/global_setup_wizard.py +363 -0
  59. titan_cli/ui/tui/screens/main_menu.py +162 -0
  60. titan_cli/ui/tui/screens/plugin_config_wizard.py +550 -0
  61. titan_cli/ui/tui/screens/plugin_management.py +377 -0
  62. titan_cli/ui/tui/screens/project_setup_wizard.py +686 -0
  63. titan_cli/ui/tui/screens/workflow_execution.py +592 -0
  64. titan_cli/ui/tui/screens/workflows.py +249 -0
  65. titan_cli/ui/tui/textual_components.py +537 -0
  66. titan_cli/ui/tui/textual_workflow_executor.py +405 -0
  67. titan_cli/ui/tui/theme.py +102 -0
  68. titan_cli/ui/tui/widgets/__init__.py +40 -0
  69. titan_cli/ui/tui/widgets/button.py +108 -0
  70. titan_cli/ui/tui/widgets/header.py +116 -0
  71. titan_cli/ui/tui/widgets/panel.py +81 -0
  72. titan_cli/ui/tui/widgets/status_bar.py +115 -0
  73. titan_cli/ui/tui/widgets/table.py +77 -0
  74. titan_cli/ui/tui/widgets/text.py +177 -0
  75. titan_cli/utils/__init__.py +0 -0
  76. titan_cli/utils/autoupdate.py +155 -0
  77. titan_cli-0.1.0.dist-info/METADATA +149 -0
  78. titan_cli-0.1.0.dist-info/RECORD +146 -0
  79. titan_cli-0.1.0.dist-info/WHEEL +4 -0
  80. titan_cli-0.1.0.dist-info/entry_points.txt +9 -0
  81. titan_cli-0.1.0.dist-info/licenses/LICENSE +201 -0
  82. titan_plugin_git/__init__.py +1 -0
  83. titan_plugin_git/clients/__init__.py +8 -0
  84. titan_plugin_git/clients/git_client.py +772 -0
  85. titan_plugin_git/exceptions.py +40 -0
  86. titan_plugin_git/messages.py +112 -0
  87. titan_plugin_git/models.py +39 -0
  88. titan_plugin_git/plugin.py +118 -0
  89. titan_plugin_git/steps/__init__.py +1 -0
  90. titan_plugin_git/steps/ai_commit_message_step.py +171 -0
  91. titan_plugin_git/steps/branch_steps.py +104 -0
  92. titan_plugin_git/steps/commit_step.py +80 -0
  93. titan_plugin_git/steps/push_step.py +63 -0
  94. titan_plugin_git/steps/status_step.py +59 -0
  95. titan_plugin_git/workflows/__previews__/__init__.py +1 -0
  96. titan_plugin_git/workflows/__previews__/commit_ai_preview.py +124 -0
  97. titan_plugin_git/workflows/commit-ai.yaml +28 -0
  98. titan_plugin_github/__init__.py +11 -0
  99. titan_plugin_github/agents/__init__.py +6 -0
  100. titan_plugin_github/agents/config_loader.py +130 -0
  101. titan_plugin_github/agents/issue_generator.py +353 -0
  102. titan_plugin_github/agents/pr_agent.py +528 -0
  103. titan_plugin_github/clients/__init__.py +8 -0
  104. titan_plugin_github/clients/github_client.py +1105 -0
  105. titan_plugin_github/config/__init__.py +0 -0
  106. titan_plugin_github/config/pr_agent.toml +85 -0
  107. titan_plugin_github/exceptions.py +28 -0
  108. titan_plugin_github/messages.py +88 -0
  109. titan_plugin_github/models.py +330 -0
  110. titan_plugin_github/plugin.py +131 -0
  111. titan_plugin_github/steps/__init__.py +12 -0
  112. titan_plugin_github/steps/ai_pr_step.py +172 -0
  113. titan_plugin_github/steps/create_pr_step.py +86 -0
  114. titan_plugin_github/steps/github_prompt_steps.py +171 -0
  115. titan_plugin_github/steps/issue_steps.py +143 -0
  116. titan_plugin_github/steps/preview_step.py +40 -0
  117. titan_plugin_github/utils.py +82 -0
  118. titan_plugin_github/workflows/__previews__/__init__.py +1 -0
  119. titan_plugin_github/workflows/__previews__/create_pr_ai_preview.py +140 -0
  120. titan_plugin_github/workflows/create-issue-ai.yaml +32 -0
  121. titan_plugin_github/workflows/create-pr-ai.yaml +49 -0
  122. titan_plugin_jira/__init__.py +8 -0
  123. titan_plugin_jira/agents/__init__.py +6 -0
  124. titan_plugin_jira/agents/config_loader.py +154 -0
  125. titan_plugin_jira/agents/jira_agent.py +553 -0
  126. titan_plugin_jira/agents/prompts.py +364 -0
  127. titan_plugin_jira/agents/response_parser.py +435 -0
  128. titan_plugin_jira/agents/token_tracker.py +223 -0
  129. titan_plugin_jira/agents/validators.py +246 -0
  130. titan_plugin_jira/clients/jira_client.py +745 -0
  131. titan_plugin_jira/config/jira_agent.toml +92 -0
  132. titan_plugin_jira/config/templates/issue_analysis.md.j2 +78 -0
  133. titan_plugin_jira/exceptions.py +37 -0
  134. titan_plugin_jira/formatters/__init__.py +6 -0
  135. titan_plugin_jira/formatters/markdown_formatter.py +245 -0
  136. titan_plugin_jira/messages.py +115 -0
  137. titan_plugin_jira/models.py +89 -0
  138. titan_plugin_jira/plugin.py +264 -0
  139. titan_plugin_jira/steps/ai_analyze_issue_step.py +105 -0
  140. titan_plugin_jira/steps/get_issue_step.py +82 -0
  141. titan_plugin_jira/steps/prompt_select_issue_step.py +80 -0
  142. titan_plugin_jira/steps/search_saved_query_step.py +238 -0
  143. titan_plugin_jira/utils/__init__.py +13 -0
  144. titan_plugin_jira/utils/issue_sorter.py +140 -0
  145. titan_plugin_jira/utils/saved_queries.py +150 -0
  146. titan_plugin_jira/workflows/analyze-jira-issues.yaml +34 -0
@@ -0,0 +1,772 @@
1
+ # plugins/titan-plugin-git/titan_plugin_git/clients/git_client.py
2
+ import subprocess
3
+ import re
4
+ import shutil # Added for shutil.which
5
+ from typing import List, Optional, Tuple
6
+ from datetime import datetime
7
+
8
+ from ..models import GitBranch, GitStatus
9
+ from ..exceptions import (
10
+ GitError,
11
+ GitClientError,
12
+ GitCommandError,
13
+ GitBranchNotFoundError,
14
+ GitDirtyWorkingTreeError,
15
+ GitNotRepositoryError,
16
+ GitMergeConflictError
17
+ )
18
+ from ..messages import msg
19
+
20
+
21
+ class GitClient:
22
+ """
23
+ Git client using subprocess
24
+
25
+ Wraps git commands and provides a Pythonic interface.
26
+
27
+ Examples:
28
+ >>> client = GitClient()
29
+ >>> branch = client.get_current_branch()
30
+ >>> print(branch)
31
+ 'develop'
32
+ """
33
+
34
+ def __init__(
35
+ self,
36
+ repo_path: str = ".",
37
+ main_branch: str = "main",
38
+ default_remote: str = "origin"
39
+ ):
40
+ """
41
+ Initialize Git client
42
+
43
+ Args:
44
+ repo_path: Path to git repository (default: current directory)
45
+ main_branch: Main branch name (from config)
46
+ default_remote: Default remote name (from config)
47
+ """
48
+ self.repo_path = repo_path
49
+ self.main_branch = main_branch
50
+ self.default_remote = default_remote
51
+ self._original_branch: Optional[str] = None
52
+ self._stash_message: Optional[str] = None
53
+ self._stashed: bool = False
54
+ self._check_git_installed() # Check git installation here
55
+ self._check_repository()
56
+
57
+ def _check_git_installed(self) -> None:
58
+ """Check if git CLI is installed."""
59
+ if not shutil.which("git"):
60
+ raise GitClientError(msg.Git.CLI_NOT_FOUND)
61
+
62
+ def _check_repository(self) -> None:
63
+ """Check if current directory is a git repository"""
64
+ try:
65
+ self._run_command(["git", "rev-parse", "--is-inside-work-tree"], check=False) # More robust check
66
+ except GitCommandError:
67
+ raise GitNotRepositoryError(msg.Git.NOT_A_REPOSITORY.format(repo_path=self.repo_path))
68
+
69
+ def _run_command(self, args: List[str], check: bool = True) -> str:
70
+ """
71
+ Run git command and return stdout
72
+
73
+ Args:
74
+ args: Command arguments (including 'git')
75
+ check: Raise exception on error
76
+
77
+ Returns:
78
+ Command stdout as string
79
+
80
+ Raises:
81
+ GitCommandError: If command fails
82
+ GitNotRepositoryError: If not in a git repository
83
+ """
84
+ try:
85
+ result = subprocess.run(
86
+ args,
87
+ cwd=self.repo_path,
88
+ capture_output=True,
89
+ text=True,
90
+ check=check
91
+ )
92
+ return result.stdout.strip()
93
+ except subprocess.CalledProcessError as e:
94
+ error_msg = e.stderr.strip() if e.stderr else str(e)
95
+ if "not a git repository" in error_msg:
96
+ raise GitNotRepositoryError(msg.Git.NOT_A_REPOSITORY.format(repo_path=self.repo_path))
97
+ raise GitCommandError(msg.Git.COMMAND_FAILED.format(error_msg=error_msg)) from e
98
+ except FileNotFoundError:
99
+ raise GitClientError(msg.Git.CLI_NOT_FOUND) # Should be caught by _check_git_installed, but safety
100
+ except Exception as e:
101
+ raise GitError(msg.Git.UNEXPECTED_ERROR.format(e=e)) from e
102
+
103
+ def get_current_branch(self) -> str:
104
+ """
105
+ Get current branch name
106
+
107
+ Returns:
108
+ Current branch name
109
+ """
110
+ return self._run_command(["git", "rev-parse", "--abbrev-ref", "HEAD"])
111
+
112
+ def get_status(self) -> GitStatus:
113
+ """
114
+ Get repository status
115
+
116
+ Returns:
117
+ GitStatus object
118
+ """
119
+ branch = self.get_current_branch()
120
+
121
+ status_output = self._run_command(["git", "status", "--short"])
122
+
123
+ modified = []
124
+ untracked = []
125
+ staged = []
126
+
127
+ for line in status_output.splitlines():
128
+ if not line.strip():
129
+ continue
130
+
131
+ status_code = line[:2]
132
+ file_path = line[3:].strip()
133
+
134
+ if status_code[0] != ' ' and status_code[0] != '?':
135
+ staged.append(file_path)
136
+
137
+ if status_code[1] == 'M':
138
+ modified.append(file_path)
139
+ elif status_code == '??':
140
+ untracked.append(file_path)
141
+
142
+ is_clean = not (modified or untracked or staged)
143
+
144
+ ahead, behind = self._get_upstream_status()
145
+
146
+ return GitStatus(
147
+ branch=branch,
148
+ is_clean=is_clean,
149
+ modified_files=modified,
150
+ untracked_files=untracked,
151
+ staged_files=staged,
152
+ ahead=ahead,
153
+ behind=behind
154
+ )
155
+
156
+ def _get_upstream_status(self) -> Tuple[int, int]:
157
+ """Get commits ahead/behind upstream"""
158
+ try:
159
+ output = self._run_command(
160
+ ["git", "rev-list", "--left-right", "--count", "HEAD...@{u}"],
161
+ check=False
162
+ )
163
+ if output:
164
+ # Output might be empty if no upstream or no diff
165
+ parts = output.split()
166
+ if len(parts) == 2:
167
+ return int(parts[0]), int(parts[1])
168
+ except GitCommandError:
169
+ # No upstream configured
170
+ pass
171
+ return 0, 0
172
+
173
+ def checkout(self, branch: str) -> None:
174
+ """
175
+ Checkout a branch
176
+
177
+ Args:
178
+ branch: Branch name to checkout
179
+
180
+ Raises:
181
+ GitBranchNotFoundError: If branch doesn't exist
182
+ GitDirtyWorkingTreeError: If working tree is dirty
183
+ """
184
+ # Check if branch exists locally or remotely
185
+ try:
186
+ self._run_command(["git", "show-ref", "--verify", f"refs/heads/{branch}"], check=False)
187
+ self._run_command(["git", "show-ref", "--verify", f"refs/remotes/origin/{branch}"], check=False)
188
+ except GitCommandError: # if both fail, it means it doesn't exist
189
+ raise GitBranchNotFoundError(msg.Git.BRANCH_NOT_FOUND.format(branch=branch))
190
+
191
+ # Checkout
192
+ try:
193
+ self._run_command(["git", "checkout", branch])
194
+ except GitCommandError as e:
195
+ if msg.Git.UNCOMMITTED_CHANGES_OVERWRITE_KEYWORD in e.stderr: # Access stderr from raised exception
196
+ raise GitDirtyWorkingTreeError(
197
+ msg.Git.CANNOT_CHECKOUT_UNCOMMITTED_CHANGES
198
+ )
199
+ raise
200
+
201
+ def update_branch(self, branch: str, remote: Optional[str] = None) -> None:
202
+ """
203
+ Update branch from remote (fetch + merge --ff-only)
204
+
205
+ Args:
206
+ branch: Branch to update
207
+ remote: Remote name (defaults to configured default_remote)
208
+ """
209
+ remote = remote or self.default_remote
210
+ current = self.get_current_branch()
211
+
212
+ if current != branch:
213
+ self.checkout(branch)
214
+
215
+ self._run_command(["git", "fetch", remote, branch])
216
+
217
+ try:
218
+ self._run_command(["git", "merge", "--ff-only", f"{remote}/{branch}"])
219
+ except GitCommandError as e:
220
+ if msg.Git.MERGE_CONFLICT_KEYWORD in e.stderr:
221
+ raise GitMergeConflictError(msg.Git.MERGE_CONFLICT_WHILE_UPDATING.format(branch=branch))
222
+ raise
223
+
224
+ if current != branch:
225
+ self.checkout(current)
226
+
227
+ def get_branches(self, remote: bool = False) -> List[GitBranch]:
228
+ """
229
+ List branches
230
+
231
+ Args:
232
+ remote: List remote branches instead of local
233
+
234
+ Returns:
235
+ List of GitBranch objects
236
+ """
237
+ args = ["git", "branch"]
238
+ if remote:
239
+ args.append("-r")
240
+ else:
241
+ args.append("-l") # Explicitly list local branches
242
+
243
+ output = self._run_command(args)
244
+
245
+ branches = []
246
+ for line in output.splitlines():
247
+ is_current = line.startswith("*")
248
+ name = line[2:].strip() if is_current else line.strip()
249
+
250
+ if name.startswith("origin/HEAD"): # Skip 'origin/HEAD -> origin/main' type refs
251
+ continue
252
+
253
+ is_remote = remote
254
+ upstream = None
255
+
256
+ # Try to get upstream if local branch
257
+ if not remote and is_current:
258
+ try:
259
+ upstream_output = self._run_command([
260
+ "git", "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"
261
+ ])
262
+ upstream = upstream_output.strip()
263
+ except GitCommandError:
264
+ pass # No upstream configured
265
+
266
+ branches.append(GitBranch(
267
+ name=name,
268
+ is_current=is_current,
269
+ is_remote=is_remote,
270
+ upstream=upstream
271
+ ))
272
+
273
+ return branches
274
+
275
+ def create_branch(self, branch_name: str, start_point: str = "HEAD") -> None:
276
+ """
277
+ Create a new branch
278
+
279
+ Args:
280
+ branch_name: Name for new branch
281
+ start_point: Starting point (commit/branch)
282
+ """
283
+ self._run_command(["git", "branch", branch_name, start_point])
284
+
285
+ def commit(self, message: str, all: bool = False) -> str:
286
+ """
287
+ Create a commit
288
+
289
+ Args:
290
+ message: Commit message
291
+ all: Stage all modified and new files (`git add --all`)
292
+
293
+ Returns:
294
+ Commit hash
295
+ """
296
+ if all:
297
+ # Stage all changes, including new files.
298
+ self._run_command(["git", "add", "--all"])
299
+
300
+ args = ["git", "commit", "-m", message]
301
+ self._run_command(args)
302
+
303
+ return self._run_command(["git", "rev-parse", "HEAD"])
304
+
305
+ def get_commits_vs_base(self) -> List[str]:
306
+ """
307
+ Get commit messages from base branch to HEAD
308
+
309
+ Returns:
310
+ List of commit messages
311
+ """
312
+ result = self._run_command([
313
+ "git", "log", "--oneline",
314
+ f"{self.main_branch}..HEAD",
315
+ "--pretty=format:%s"
316
+ ])
317
+
318
+ if not result:
319
+ return []
320
+
321
+ return [line.strip() for line in result.split('\n') if line.strip()]
322
+
323
+ def update_from_main(self) -> None:
324
+ """
325
+ Update current branch from the configured main branch.
326
+ """
327
+ self.update_branch(self.main_branch, self.default_remote)
328
+
329
+ def safe_delete_branch(self, branch: str, force: bool = False) -> bool:
330
+ """
331
+ Delete branch if not protected.
332
+
333
+ Args:
334
+ branch: Branch name to delete
335
+ force: Force deletion (git branch -D)
336
+
337
+ Returns:
338
+ True if deleted, False if protected
339
+
340
+ Raises:
341
+ GitError: if branch is protected
342
+ """
343
+ if self.is_protected_branch(branch):
344
+ raise GitError(msg.Git.BRANCH_PROTECTED.format(branch=branch))
345
+
346
+ delete_arg = "-D" if force else "-d"
347
+ self._run_command(["git", "branch", delete_arg, branch])
348
+ return True
349
+
350
+ def push(self, remote: str = "origin", branch: Optional[str] = None, set_upstream: bool = False) -> None:
351
+ """
352
+ Push to remote
353
+
354
+ Args:
355
+ remote: Remote name
356
+ branch: Branch to push (default: current)
357
+ set_upstream: Set upstream tracking
358
+ """
359
+ args = ["git", "push"]
360
+
361
+ if set_upstream:
362
+ args.append("-u")
363
+
364
+ args.append(remote)
365
+
366
+ if branch:
367
+ args.append(branch)
368
+
369
+ self._run_command(args)
370
+
371
+ def pull(self, remote: str = "origin", branch: Optional[str] = None) -> None:
372
+ """
373
+ Pull from remote
374
+
375
+ Args:
376
+ remote: Remote name
377
+ branch: Branch to pull (default: current)
378
+ """
379
+ args = ["git", "pull", remote]
380
+
381
+ if branch:
382
+ args.append(branch)
383
+
384
+ self._run_command(args)
385
+
386
+ def fetch(self, remote: str = "origin", all: bool = False) -> None:
387
+ """
388
+ Fetch from remote
389
+
390
+ Args:
391
+ remote: Remote name
392
+ all: Fetch from all remotes
393
+ """
394
+ args = ["git", "fetch"]
395
+
396
+ if all:
397
+ args.append("--all")
398
+ else:
399
+ args.append(remote)
400
+
401
+ self._run_command(args)
402
+
403
+ def has_uncommitted_changes(self) -> bool:
404
+ """
405
+ Check if there are uncommitted changes
406
+
407
+ Returns:
408
+ True if there are uncommitted changes
409
+ """
410
+ # git status --porcelain will output if there are any changes (staged or unstaged)
411
+ status_output = self._run_command(["git", "status", "--porcelain"], check=False)
412
+ return bool(status_output.strip())
413
+
414
+ def stash_push(self, message: Optional[str] = None) -> bool:
415
+ """
416
+ Stash uncommitted changes
417
+
418
+ Args:
419
+ message: Optional stash message
420
+
421
+ Returns:
422
+ True if stash was created
423
+ """
424
+
425
+ if not message:
426
+ message = msg.Git.AUTO_STASH_MESSAGE.format(timestamp=datetime.now().strftime('%Y-%m-%d %H:%M:%S'))
427
+
428
+ try:
429
+ self._run_command(["git", "stash", "push", "-m", message])
430
+ self._stashed = True
431
+ self._stash_message = message
432
+ return True
433
+ except GitCommandError:
434
+ return False
435
+
436
+ def stash_pop(self, stash_ref: Optional[str] = None) -> bool:
437
+ """
438
+ Pop stash (apply and remove)
439
+
440
+ Args:
441
+ stash_ref: Optional stash reference (default: latest)
442
+
443
+ Returns:
444
+ True if stash was applied successfully
445
+ """
446
+ try:
447
+ args = ["git", "stash", "pop"]
448
+ if stash_ref:
449
+ args.append(stash_ref)
450
+
451
+ self._run_command(args)
452
+ return True
453
+ except GitCommandError:
454
+ return False
455
+
456
+ def find_stash_by_message(self, message: str) -> Optional[str]:
457
+ """
458
+ Find stash by message
459
+
460
+ Args:
461
+ message: Stash message to search for
462
+
463
+ Returns:
464
+ Stash reference (e.g., "stash@{0}") or None
465
+ """
466
+ try:
467
+ output = self._run_command(["git", "stash", "list"])
468
+
469
+ for line in output.splitlines():
470
+ if message in line:
471
+ stash_ref = line.split(':')[0].strip()
472
+ return stash_ref
473
+
474
+ return None
475
+ except GitCommandError:
476
+ return None
477
+
478
+ def restore_stash(self) -> bool:
479
+ """
480
+ Restore stash created by this client
481
+
482
+ Returns:
483
+ True if stash was restored successfully
484
+ """
485
+ if not self._stashed or not self._stash_message:
486
+ return True
487
+
488
+ stash_ref = self.find_stash_by_message(self._stash_message)
489
+
490
+ if stash_ref:
491
+ if self.stash_pop(stash_ref):
492
+ self._stashed = False
493
+ self._stash_message = None
494
+ return True
495
+ else:
496
+ return False
497
+ else:
498
+ return False
499
+
500
+ def safe_checkout(self, branch: str, auto_stash: bool = True) -> bool:
501
+ """
502
+ Checkout branch safely with auto-stash
503
+
504
+ Args:
505
+ branch: Branch to checkout
506
+ auto_stash: Automatically stash changes if needed
507
+
508
+ Returns:
509
+ True if checkout was successful
510
+
511
+ Raises:
512
+ GitDirtyWorkingTreeError: If changes exist and auto_stash is False
513
+ """
514
+ if not self._original_branch:
515
+ self._original_branch = self.get_current_branch()
516
+
517
+ current = self.get_current_branch()
518
+
519
+ if current == branch:
520
+ return True
521
+
522
+ if self.has_uncommitted_changes():
523
+ if not auto_stash:
524
+ raise GitDirtyWorkingTreeError(
525
+ msg.Git.CANNOT_CHECKOUT_UNCOMMITTED_CHANGES_EXIST.format(branch=branch)
526
+ )
527
+
528
+ message = msg.Git.SAFE_SWITCH_STASH_MESSAGE.format(current=current, branch=branch)
529
+ if not self.stash_push(message):
530
+ raise GitDirtyWorkingTreeError(
531
+ msg.Git.STASH_FAILED_BEFORE_CHECKOUT
532
+ )
533
+
534
+ try:
535
+ self.checkout(branch)
536
+ return True
537
+ except Exception:
538
+ if self._stashed:
539
+ self.restore_stash()
540
+ raise
541
+
542
+ def return_to_original_branch(self) -> bool:
543
+ """
544
+ Return to original branch and restore stash
545
+
546
+ Returns:
547
+ True if successfully returned
548
+ """
549
+ if not self._original_branch:
550
+ return True
551
+
552
+ current = self.get_current_branch()
553
+
554
+ if current == self._original_branch:
555
+ if self._stashed:
556
+ return self.restore_stash()
557
+ return True
558
+
559
+ try:
560
+ self.checkout(self._original_branch)
561
+
562
+ if self._stashed:
563
+ self.restore_stash()
564
+
565
+ self._original_branch = None
566
+
567
+ return True
568
+ except Exception:
569
+ return False
570
+
571
+ def branch_exists_on_remote(self, branch: str, remote: str = "origin") -> bool:
572
+ """
573
+ Check if a branch exists on remote
574
+
575
+ Args:
576
+ branch: Branch name to check
577
+ remote: Remote name (default: "origin")
578
+
579
+ Returns:
580
+ True if branch exists on remote, False otherwise
581
+ """
582
+ try:
583
+ result = self._run_command([
584
+ "git", "ls-remote", "--heads", remote, branch
585
+ ], check=False) # check=False because ls-remote returns 1 if branch not found
586
+ return bool(result.strip())
587
+ except GitCommandError:
588
+ return False
589
+
590
+ def count_commits_ahead(self, base_branch: str = "develop") -> int:
591
+ """
592
+ Count how many commits current branch is ahead of base branch
593
+
594
+ Args:
595
+ base_branch: Base branch to compare against (default: "develop")
596
+
597
+ Returns:
598
+ Number of commits ahead
599
+ """
600
+ try:
601
+ result = self._run_command([
602
+ "git", "rev-list", "--count", f"{base_branch}..HEAD"
603
+ ])
604
+ return int(result.strip())
605
+ except (GitCommandError, ValueError):
606
+ return 0
607
+
608
+ def count_unpushed_commits(self, branch: Optional[str] = None, remote: str = "origin") -> int:
609
+ """
610
+ Count how many commits are unpushed to remote
611
+
612
+ Args:
613
+ branch: Branch name (default: current branch)
614
+ remote: Remote name (default: "origin")
615
+
616
+ Returns:
617
+ Number of unpushed commits, or 0 if branch doesn't have upstream
618
+ """
619
+ try:
620
+ if branch is None:
621
+ branch = self.get_current_branch()
622
+
623
+ result = self._run_command([
624
+ "git", "rev-list", "--count", f"{remote}/{branch}..HEAD"
625
+ ])
626
+ return int(result.strip())
627
+ except (GitCommandError, ValueError):
628
+ return 0
629
+
630
+ def get_diff(self, base_ref: str, head_ref: str = "HEAD") -> str:
631
+ """
632
+ Get diff between two references
633
+
634
+ Args:
635
+ base_ref: Base reference (branch, commit, tag)
636
+ head_ref: Head reference (default: "HEAD")
637
+
638
+ Returns:
639
+ Diff output as string
640
+ """
641
+ try:
642
+ # git diff can return a non-zero exit code if there are differences,
643
+ # so we use check=False and handle the output.
644
+ return self._run_command(
645
+ ["git", "diff", f"{base_ref}...{head_ref}"],
646
+ check=False
647
+ )
648
+ except GitCommandError:
649
+ # This might be raised for other reasons, returning empty is safe.
650
+ return ""
651
+
652
+ def get_uncommitted_diff(self) -> str:
653
+ """
654
+ Get diff of all uncommitted changes (staged + unstaged + untracked).
655
+
656
+ Uses git add --intent-to-add to make untracked files visible in the diff
657
+ without actually staging their content.
658
+
659
+ Returns:
660
+ Diff output as string
661
+ """
662
+ try:
663
+ # Add untracked files to index without staging content
664
+ # This makes new files visible to git diff HEAD
665
+ self._run_command(["git", "add", "--intent-to-add", "."], check=False)
666
+
667
+ # git diff HEAD shows all changes vs last commit (staged + unstaged + untracked)
668
+ return self._run_command(["git", "diff", "HEAD"], check=False)
669
+ except GitCommandError:
670
+ return ""
671
+
672
+ def get_staged_diff(self) -> str:
673
+ """
674
+ Get diff of staged changes only (index vs HEAD).
675
+
676
+ Returns:
677
+ Diff output as string
678
+ """
679
+ try:
680
+ # git diff --cached shows only staged changes
681
+ return self._run_command(["git", "diff", "--cached"], check=False)
682
+ except GitCommandError:
683
+ return ""
684
+
685
+ def get_unstaged_diff(self) -> str:
686
+ """
687
+ Get diff of unstaged changes only (working directory vs index).
688
+
689
+ Returns:
690
+ Diff output as string
691
+ """
692
+ try:
693
+ # git diff shows only unstaged changes
694
+ return self._run_command(["git", "diff"], check=False)
695
+ except GitCommandError:
696
+ return ""
697
+
698
+ def get_file_diff(self, file_path: str) -> str:
699
+ """
700
+ Get diff for a specific file.
701
+
702
+ Args:
703
+ file_path: Path to the file
704
+
705
+ Returns:
706
+ Diff output as string
707
+ """
708
+ try:
709
+ return self._run_command(["git", "diff", "HEAD", "--", file_path], check=False)
710
+ except GitCommandError:
711
+ return ""
712
+
713
+ def get_branch_diff(self, base_branch: str, head_branch: str) -> str:
714
+ """
715
+ Get diff between two branches.
716
+
717
+ Args:
718
+ base_branch: Base branch name
719
+ head_branch: Head branch name
720
+
721
+ Returns:
722
+ Diff output as string
723
+ """
724
+ try:
725
+ return self._run_command(
726
+ ["git", "diff", f"{base_branch}...{head_branch}"],
727
+ check=False
728
+ )
729
+ except GitCommandError:
730
+ return ""
731
+
732
+ def get_branch_commits(self, base_branch: str, head_branch: str) -> list[str]:
733
+ """
734
+ Get list of commits in head_branch that are not in base_branch.
735
+
736
+ Args:
737
+ base_branch: Base branch name
738
+ head_branch: Head branch name
739
+
740
+ Returns:
741
+ List of commit messages
742
+ """
743
+ try:
744
+ output = self._run_command([
745
+ "git", "log",
746
+ f"{base_branch}..{head_branch}",
747
+ "--pretty=format:%s"
748
+ ])
749
+ if output.strip():
750
+ return output.strip().split("\n")
751
+ return []
752
+ except GitCommandError:
753
+ return []
754
+
755
+ def get_github_repo_info(self) -> Tuple[Optional[str], Optional[str]]:
756
+ """
757
+ Extracts GitHub repository owner and name from the 'origin' remote URL.
758
+
759
+ Returns:
760
+ A tuple containing (repo_owner, repo_name) if detected, otherwise (None, None).
761
+ """
762
+ try:
763
+ url = self._run_command(["git", "remote", "get-url", "origin"])
764
+
765
+ # Parse: git@github.com:owner/repo.git or https://github.com/owner/repo.git
766
+ match = re.search(r'github\.com[:/]([^/]+)/([^/.]+)', url)
767
+ if match:
768
+ return match.group(1), match.group(2)
769
+ except GitCommandError:
770
+ # Command failed, likely no remote 'origin' or not a git repo
771
+ pass
772
+ return None, None