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.
- titan_cli/__init__.py +3 -0
- titan_cli/__main__.py +4 -0
- titan_cli/ai/__init__.py +0 -0
- titan_cli/ai/agents/__init__.py +15 -0
- titan_cli/ai/agents/base.py +152 -0
- titan_cli/ai/client.py +170 -0
- titan_cli/ai/constants.py +56 -0
- titan_cli/ai/exceptions.py +48 -0
- titan_cli/ai/models.py +34 -0
- titan_cli/ai/oauth_helper.py +120 -0
- titan_cli/ai/providers/__init__.py +9 -0
- titan_cli/ai/providers/anthropic.py +117 -0
- titan_cli/ai/providers/base.py +75 -0
- titan_cli/ai/providers/gemini.py +278 -0
- titan_cli/cli.py +59 -0
- titan_cli/clients/__init__.py +1 -0
- titan_cli/clients/gcloud_client.py +52 -0
- titan_cli/core/__init__.py +3 -0
- titan_cli/core/config.py +274 -0
- titan_cli/core/discovery.py +51 -0
- titan_cli/core/errors.py +81 -0
- titan_cli/core/models.py +52 -0
- titan_cli/core/plugins/available.py +36 -0
- titan_cli/core/plugins/models.py +67 -0
- titan_cli/core/plugins/plugin_base.py +108 -0
- titan_cli/core/plugins/plugin_registry.py +163 -0
- titan_cli/core/secrets.py +141 -0
- titan_cli/core/workflows/__init__.py +22 -0
- titan_cli/core/workflows/models.py +88 -0
- titan_cli/core/workflows/project_step_source.py +86 -0
- titan_cli/core/workflows/workflow_exceptions.py +17 -0
- titan_cli/core/workflows/workflow_filter_service.py +137 -0
- titan_cli/core/workflows/workflow_registry.py +419 -0
- titan_cli/core/workflows/workflow_sources.py +307 -0
- titan_cli/engine/__init__.py +39 -0
- titan_cli/engine/builder.py +159 -0
- titan_cli/engine/context.py +82 -0
- titan_cli/engine/mock_context.py +176 -0
- titan_cli/engine/results.py +91 -0
- titan_cli/engine/steps/ai_assistant_step.py +185 -0
- titan_cli/engine/steps/command_step.py +93 -0
- titan_cli/engine/utils/__init__.py +3 -0
- titan_cli/engine/utils/venv.py +31 -0
- titan_cli/engine/workflow_executor.py +187 -0
- titan_cli/external_cli/__init__.py +0 -0
- titan_cli/external_cli/configs.py +17 -0
- titan_cli/external_cli/launcher.py +65 -0
- titan_cli/messages.py +121 -0
- titan_cli/ui/tui/__init__.py +205 -0
- titan_cli/ui/tui/__previews__/statusbar_preview.py +88 -0
- titan_cli/ui/tui/app.py +113 -0
- titan_cli/ui/tui/icons.py +70 -0
- titan_cli/ui/tui/screens/__init__.py +24 -0
- titan_cli/ui/tui/screens/ai_config.py +498 -0
- titan_cli/ui/tui/screens/ai_config_wizard.py +882 -0
- titan_cli/ui/tui/screens/base.py +110 -0
- titan_cli/ui/tui/screens/cli_launcher.py +151 -0
- titan_cli/ui/tui/screens/global_setup_wizard.py +363 -0
- titan_cli/ui/tui/screens/main_menu.py +162 -0
- titan_cli/ui/tui/screens/plugin_config_wizard.py +550 -0
- titan_cli/ui/tui/screens/plugin_management.py +377 -0
- titan_cli/ui/tui/screens/project_setup_wizard.py +686 -0
- titan_cli/ui/tui/screens/workflow_execution.py +592 -0
- titan_cli/ui/tui/screens/workflows.py +249 -0
- titan_cli/ui/tui/textual_components.py +537 -0
- titan_cli/ui/tui/textual_workflow_executor.py +405 -0
- titan_cli/ui/tui/theme.py +102 -0
- titan_cli/ui/tui/widgets/__init__.py +40 -0
- titan_cli/ui/tui/widgets/button.py +108 -0
- titan_cli/ui/tui/widgets/header.py +116 -0
- titan_cli/ui/tui/widgets/panel.py +81 -0
- titan_cli/ui/tui/widgets/status_bar.py +115 -0
- titan_cli/ui/tui/widgets/table.py +77 -0
- titan_cli/ui/tui/widgets/text.py +177 -0
- titan_cli/utils/__init__.py +0 -0
- titan_cli/utils/autoupdate.py +155 -0
- titan_cli-0.1.0.dist-info/METADATA +149 -0
- titan_cli-0.1.0.dist-info/RECORD +146 -0
- titan_cli-0.1.0.dist-info/WHEEL +4 -0
- titan_cli-0.1.0.dist-info/entry_points.txt +9 -0
- titan_cli-0.1.0.dist-info/licenses/LICENSE +201 -0
- titan_plugin_git/__init__.py +1 -0
- titan_plugin_git/clients/__init__.py +8 -0
- titan_plugin_git/clients/git_client.py +772 -0
- titan_plugin_git/exceptions.py +40 -0
- titan_plugin_git/messages.py +112 -0
- titan_plugin_git/models.py +39 -0
- titan_plugin_git/plugin.py +118 -0
- titan_plugin_git/steps/__init__.py +1 -0
- titan_plugin_git/steps/ai_commit_message_step.py +171 -0
- titan_plugin_git/steps/branch_steps.py +104 -0
- titan_plugin_git/steps/commit_step.py +80 -0
- titan_plugin_git/steps/push_step.py +63 -0
- titan_plugin_git/steps/status_step.py +59 -0
- titan_plugin_git/workflows/__previews__/__init__.py +1 -0
- titan_plugin_git/workflows/__previews__/commit_ai_preview.py +124 -0
- titan_plugin_git/workflows/commit-ai.yaml +28 -0
- titan_plugin_github/__init__.py +11 -0
- titan_plugin_github/agents/__init__.py +6 -0
- titan_plugin_github/agents/config_loader.py +130 -0
- titan_plugin_github/agents/issue_generator.py +353 -0
- titan_plugin_github/agents/pr_agent.py +528 -0
- titan_plugin_github/clients/__init__.py +8 -0
- titan_plugin_github/clients/github_client.py +1105 -0
- titan_plugin_github/config/__init__.py +0 -0
- titan_plugin_github/config/pr_agent.toml +85 -0
- titan_plugin_github/exceptions.py +28 -0
- titan_plugin_github/messages.py +88 -0
- titan_plugin_github/models.py +330 -0
- titan_plugin_github/plugin.py +131 -0
- titan_plugin_github/steps/__init__.py +12 -0
- titan_plugin_github/steps/ai_pr_step.py +172 -0
- titan_plugin_github/steps/create_pr_step.py +86 -0
- titan_plugin_github/steps/github_prompt_steps.py +171 -0
- titan_plugin_github/steps/issue_steps.py +143 -0
- titan_plugin_github/steps/preview_step.py +40 -0
- titan_plugin_github/utils.py +82 -0
- titan_plugin_github/workflows/__previews__/__init__.py +1 -0
- titan_plugin_github/workflows/__previews__/create_pr_ai_preview.py +140 -0
- titan_plugin_github/workflows/create-issue-ai.yaml +32 -0
- titan_plugin_github/workflows/create-pr-ai.yaml +49 -0
- titan_plugin_jira/__init__.py +8 -0
- titan_plugin_jira/agents/__init__.py +6 -0
- titan_plugin_jira/agents/config_loader.py +154 -0
- titan_plugin_jira/agents/jira_agent.py +553 -0
- titan_plugin_jira/agents/prompts.py +364 -0
- titan_plugin_jira/agents/response_parser.py +435 -0
- titan_plugin_jira/agents/token_tracker.py +223 -0
- titan_plugin_jira/agents/validators.py +246 -0
- titan_plugin_jira/clients/jira_client.py +745 -0
- titan_plugin_jira/config/jira_agent.toml +92 -0
- titan_plugin_jira/config/templates/issue_analysis.md.j2 +78 -0
- titan_plugin_jira/exceptions.py +37 -0
- titan_plugin_jira/formatters/__init__.py +6 -0
- titan_plugin_jira/formatters/markdown_formatter.py +245 -0
- titan_plugin_jira/messages.py +115 -0
- titan_plugin_jira/models.py +89 -0
- titan_plugin_jira/plugin.py +264 -0
- titan_plugin_jira/steps/ai_analyze_issue_step.py +105 -0
- titan_plugin_jira/steps/get_issue_step.py +82 -0
- titan_plugin_jira/steps/prompt_select_issue_step.py +80 -0
- titan_plugin_jira/steps/search_saved_query_step.py +238 -0
- titan_plugin_jira/utils/__init__.py +13 -0
- titan_plugin_jira/utils/issue_sorter.py +140 -0
- titan_plugin_jira/utils/saved_queries.py +150 -0
- 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
|