titan-cli 0.1.4__py3-none-any.whl → 0.1.6__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 (61) hide show
  1. titan_cli/core/config.py +3 -1
  2. titan_cli/core/workflows/__init__.py +2 -1
  3. titan_cli/core/workflows/project_step_source.py +95 -32
  4. titan_cli/core/workflows/workflow_filter_service.py +16 -8
  5. titan_cli/core/workflows/workflow_registry.py +12 -1
  6. titan_cli/core/workflows/workflow_sources.py +1 -1
  7. titan_cli/engine/__init__.py +5 -1
  8. titan_cli/engine/results.py +31 -1
  9. titan_cli/engine/steps/ai_assistant_step.py +47 -12
  10. titan_cli/engine/workflow_executor.py +13 -3
  11. titan_cli/ui/tui/screens/plugin_config_wizard.py +16 -0
  12. titan_cli/ui/tui/screens/workflow_execution.py +28 -50
  13. titan_cli/ui/tui/screens/workflows.py +8 -4
  14. titan_cli/ui/tui/textual_components.py +342 -185
  15. titan_cli/ui/tui/textual_workflow_executor.py +39 -3
  16. titan_cli/ui/tui/theme.py +34 -5
  17. titan_cli/ui/tui/widgets/__init__.py +17 -0
  18. titan_cli/ui/tui/widgets/multiline_input.py +32 -0
  19. titan_cli/ui/tui/widgets/prompt_choice.py +138 -0
  20. titan_cli/ui/tui/widgets/prompt_input.py +74 -0
  21. titan_cli/ui/tui/widgets/prompt_selection_list.py +150 -0
  22. titan_cli/ui/tui/widgets/prompt_textarea.py +87 -0
  23. titan_cli/ui/tui/widgets/step_container.py +70 -0
  24. titan_cli/ui/tui/widgets/styled_option_list.py +107 -0
  25. titan_cli/ui/tui/widgets/text.py +51 -130
  26. {titan_cli-0.1.4.dist-info → titan_cli-0.1.6.dist-info}/METADATA +3 -5
  27. {titan_cli-0.1.4.dist-info → titan_cli-0.1.6.dist-info}/RECORD +61 -46
  28. titan_plugin_git/clients/git_client.py +140 -5
  29. titan_plugin_git/plugin.py +13 -0
  30. titan_plugin_git/steps/ai_commit_message_step.py +39 -34
  31. titan_plugin_git/steps/branch_steps.py +18 -37
  32. titan_plugin_git/steps/checkout_step.py +66 -0
  33. titan_plugin_git/steps/commit_step.py +18 -22
  34. titan_plugin_git/steps/create_branch_step.py +131 -0
  35. titan_plugin_git/steps/diff_summary_step.py +180 -0
  36. titan_plugin_git/steps/pull_step.py +70 -0
  37. titan_plugin_git/steps/push_step.py +27 -11
  38. titan_plugin_git/steps/restore_original_branch_step.py +97 -0
  39. titan_plugin_git/steps/save_current_branch_step.py +82 -0
  40. titan_plugin_git/steps/status_step.py +32 -25
  41. titan_plugin_git/workflows/commit-ai.yaml +9 -3
  42. titan_plugin_github/agents/pr_agent.py +15 -2
  43. titan_plugin_github/steps/ai_pr_step.py +99 -40
  44. titan_plugin_github/steps/create_pr_step.py +18 -8
  45. titan_plugin_github/steps/github_prompt_steps.py +53 -1
  46. titan_plugin_github/steps/issue_steps.py +31 -18
  47. titan_plugin_github/steps/preview_step.py +15 -4
  48. titan_plugin_github/utils.py +5 -4
  49. titan_plugin_github/workflows/create-pr-ai.yaml +6 -11
  50. titan_plugin_jira/messages.py +12 -0
  51. titan_plugin_jira/plugin.py +4 -0
  52. titan_plugin_jira/steps/ai_analyze_issue_step.py +12 -7
  53. titan_plugin_jira/steps/get_issue_step.py +17 -13
  54. titan_plugin_jira/steps/list_versions_step.py +133 -0
  55. titan_plugin_jira/steps/prompt_select_issue_step.py +20 -8
  56. titan_plugin_jira/steps/search_jql_step.py +191 -0
  57. titan_plugin_jira/steps/search_saved_query_step.py +26 -24
  58. titan_plugin_jira/utils/__init__.py +1 -1
  59. {titan_cli-0.1.4.dist-info → titan_cli-0.1.6.dist-info}/LICENSE +0 -0
  60. {titan_cli-0.1.4.dist-info → titan_cli-0.1.6.dist-info}/WHEEL +0 -0
  61. {titan_cli-0.1.4.dist-info → titan_cli-0.1.6.dist-info}/entry_points.txt +0 -0
@@ -0,0 +1,131 @@
1
+ """
2
+ Create a new Git branch.
3
+ """
4
+
5
+ from titan_cli.engine import WorkflowContext, WorkflowResult, Success, Error
6
+ from titan_plugin_git.exceptions import GitError
7
+
8
+
9
+ def create_branch_step(ctx: WorkflowContext) -> WorkflowResult:
10
+ """
11
+ Create a new Git branch.
12
+
13
+ Inputs (from ctx.data):
14
+ new_branch (str): Name of the branch to create
15
+ start_point (str, optional): Starting point for the branch (defaults to HEAD)
16
+ delete_if_exists (bool, optional): Delete the branch if it already exists (default: False)
17
+ checkout (bool, optional): Checkout the new branch after creation (default: True)
18
+
19
+ Returns:
20
+ Success: Branch created successfully
21
+ Error: Git operation failed
22
+ """
23
+ if ctx.textual:
24
+ ctx.textual.begin_step("Create Branch")
25
+
26
+ if not ctx.textual:
27
+ return Error("Textual UI context is not available for this step.")
28
+
29
+ if not ctx.git:
30
+ ctx.textual.error_text("Git client not available in context")
31
+ ctx.textual.end_step("error")
32
+ return Error("Git client not available in context")
33
+
34
+ try:
35
+ # Get params from context
36
+ new_branch = ctx.get("new_branch")
37
+ if not new_branch:
38
+ ctx.textual.error_text("No branch name specified")
39
+ ctx.textual.dim_text("Set 'new_branch' in workflow params or previous step")
40
+ ctx.textual.end_step("error")
41
+ return Error("No branch name specified")
42
+
43
+ start_point = ctx.get("start_point", "HEAD")
44
+ delete_if_exists = ctx.get("delete_if_exists", False)
45
+ checkout = ctx.get("checkout", True)
46
+
47
+ ctx.textual.text("")
48
+ ctx.textual.dim_text(f"Creating branch: {new_branch}")
49
+ ctx.textual.dim_text(f"From: {start_point}")
50
+
51
+ # Check if branch exists
52
+ all_branches = ctx.git.get_branches()
53
+ branch_names = [b.name for b in all_branches]
54
+ branch_exists = new_branch in branch_names
55
+
56
+ # Delete if exists and requested
57
+ if branch_exists and delete_if_exists:
58
+ ctx.textual.text("")
59
+ ctx.textual.warning_text(f"Branch {new_branch} exists, deleting...")
60
+
61
+ # If we're on the branch, checkout another one first
62
+ current_branch = ctx.git.get_current_branch()
63
+ if current_branch == new_branch:
64
+ # Checkout main branch before deleting
65
+ main_branch = ctx.git.main_branch
66
+ if main_branch in branch_names and main_branch != new_branch:
67
+ try:
68
+ ctx.git.checkout(main_branch)
69
+ ctx.textual.dim_text(f"Switched to {main_branch}")
70
+ except GitError as e:
71
+ ctx.textual.error_text(f"Failed to checkout {main_branch}: {str(e)}")
72
+ ctx.textual.end_step("error")
73
+ return Error(f"Cannot checkout {main_branch}: {str(e)}")
74
+ else:
75
+ ctx.textual.error_text(f"Cannot delete current branch {new_branch}")
76
+ ctx.textual.end_step("error")
77
+ return Error("Cannot delete current branch")
78
+
79
+ # Delete the branch
80
+ try:
81
+ ctx.git.safe_delete_branch(new_branch, force=True)
82
+ ctx.textual.success_text(f"✓ Deleted existing branch {new_branch}")
83
+ except GitError as e:
84
+ ctx.textual.error_text(f"Failed to delete {new_branch}: {str(e)}")
85
+ ctx.textual.end_step("error")
86
+ return Error(f"Failed to delete branch: {str(e)}")
87
+
88
+ elif branch_exists:
89
+ ctx.textual.error_text(f"Branch {new_branch} already exists")
90
+ ctx.textual.dim_text("Set 'delete_if_exists: true' to recreate it")
91
+ ctx.textual.end_step("error")
92
+ return Error(f"Branch {new_branch} already exists")
93
+
94
+ # Create the branch
95
+ ctx.textual.text("")
96
+ try:
97
+ ctx.git.create_branch(new_branch, start_point=start_point)
98
+ ctx.textual.success_text(f"✓ Created branch {new_branch}")
99
+ except GitError as e:
100
+ ctx.textual.error_text(f"Failed to create {new_branch}: {str(e)}")
101
+ ctx.textual.end_step("error")
102
+ return Error(f"Failed to create branch: {str(e)}")
103
+
104
+ # Checkout if requested
105
+ if checkout:
106
+ try:
107
+ ctx.git.checkout(new_branch)
108
+ ctx.textual.success_text(f"✓ Checked out {new_branch}")
109
+ except GitError as e:
110
+ ctx.textual.warning_text(f"Branch created but failed to checkout: {str(e)}")
111
+
112
+ ctx.textual.text("")
113
+ ctx.textual.end_step("success")
114
+ return Success(
115
+ f"Created branch {new_branch}",
116
+ metadata={
117
+ "new_branch": new_branch,
118
+ "start_point": start_point,
119
+ "checked_out": checkout
120
+ }
121
+ )
122
+
123
+ except Exception as e:
124
+ ctx.textual.text("")
125
+ ctx.textual.error_text(f"Failed to create branch: {str(e)}")
126
+ ctx.textual.text("")
127
+ ctx.textual.end_step("error")
128
+ return Error(f"Failed to create branch: {str(e)}")
129
+
130
+
131
+ __all__ = ["create_branch_step"]
@@ -0,0 +1,180 @@
1
+ # plugins/titan-plugin-git/titan_plugin_git/steps/diff_summary_step.py
2
+ from titan_cli.engine import WorkflowContext, WorkflowResult, Success, Error, Skip
3
+ from titan_plugin_git.messages import msg
4
+
5
+
6
+ def show_uncommitted_diff_summary(ctx: WorkflowContext) -> WorkflowResult:
7
+ """
8
+ Show summary of uncommitted changes (git diff --stat).
9
+
10
+ Provides a visual overview of files changed and lines modified
11
+ before generating commit messages.
12
+
13
+ Returns:
14
+ Success: Always (even if no changes, for workflow continuity)
15
+ """
16
+ if not ctx.textual:
17
+ return Error("Textual UI context is not available for this step.")
18
+
19
+ if not ctx.git:
20
+ return Error(msg.Steps.Push.GIT_CLIENT_NOT_AVAILABLE)
21
+
22
+ # Begin step container
23
+ ctx.textual.begin_step("Show Changes Summary")
24
+
25
+ try:
26
+ # Get diff stat for uncommitted changes
27
+ stat_output = ctx.git.get_uncommitted_diff_stat()
28
+
29
+ if not stat_output or not stat_output.strip():
30
+ ctx.textual.dim_text("No uncommitted changes to show")
31
+ ctx.textual.end_step("success")
32
+ return Success("No changes")
33
+
34
+ # Show the stat summary with colors
35
+ ctx.textual.text("") # spacing
36
+ ctx.textual.bold_text("Changes summary:")
37
+ ctx.textual.text("") # spacing
38
+
39
+ # Parse lines to find max filename length for alignment
40
+ file_lines = []
41
+ summary_lines = []
42
+ max_filename_len = 0
43
+
44
+ for line in stat_output.split('\n'):
45
+ if not line.strip():
46
+ continue
47
+
48
+ if '|' in line:
49
+ parts = line.split('|')
50
+ filename = parts[0].strip()
51
+ stats = '|'.join(parts[1:]) if len(parts) > 1 else ''
52
+ file_lines.append((filename, stats))
53
+ max_filename_len = max(max_filename_len, len(filename))
54
+ else:
55
+ summary_lines.append(line)
56
+
57
+ # Display aligned file changes
58
+ for filename, stats in file_lines:
59
+ # Pad filename to align pipes
60
+ padded_filename = filename.ljust(max_filename_len)
61
+
62
+ # Replace + with green and - with red
63
+ stats = stats.replace('+', '[green]+[/green]')
64
+ stats = stats.replace('-', '[red]-[/red]')
65
+
66
+ ctx.textual.text(f" {padded_filename} | {stats}")
67
+
68
+ # Display summary lines
69
+ for line in summary_lines:
70
+ colored_line = line.replace('(+)', '[green](+)[/green]')
71
+ colored_line = colored_line.replace('(-)', '[red](-)[/red]')
72
+ ctx.textual.dim_text(f" {colored_line}")
73
+
74
+ ctx.textual.text("") # spacing
75
+
76
+ # End step container with success
77
+ ctx.textual.end_step("success")
78
+
79
+ return Success("Diff summary displayed")
80
+
81
+ except Exception as e:
82
+ # Don't fail the workflow, just skip
83
+ ctx.textual.end_step("skip")
84
+ return Skip(f"Could not show diff summary: {e}")
85
+
86
+
87
+ def show_branch_diff_summary(ctx: WorkflowContext) -> WorkflowResult:
88
+ """
89
+ Show summary of branch changes (git diff base...head --stat).
90
+
91
+ Provides a visual overview of files changed between branches
92
+ before generating PR descriptions.
93
+
94
+ Inputs (from ctx.data):
95
+ pr_head_branch (str): Head branch name
96
+
97
+ Returns:
98
+ Success: Always (even if no changes, for workflow continuity)
99
+ """
100
+ if not ctx.textual:
101
+ return Error("Textual UI context is not available for this step.")
102
+
103
+ # Begin step container
104
+ ctx.textual.begin_step("Show Branch Changes Summary")
105
+
106
+ if not ctx.git:
107
+ ctx.textual.error_text(msg.Steps.Push.GIT_CLIENT_NOT_AVAILABLE)
108
+ ctx.textual.end_step("error")
109
+ return Error(msg.Steps.Push.GIT_CLIENT_NOT_AVAILABLE)
110
+
111
+ head_branch = ctx.get("pr_head_branch")
112
+ if not head_branch:
113
+ ctx.textual.dim_text("No head branch specified")
114
+ ctx.textual.end_step("skip")
115
+ return Skip("No head branch specified")
116
+
117
+ base_branch = ctx.git.main_branch
118
+
119
+ try:
120
+ # Get diff stat between branches
121
+ stat_output = ctx.git.get_branch_diff_stat(base_branch, head_branch)
122
+
123
+ if not stat_output or not stat_output.strip():
124
+ ctx.textual.dim_text(f"No changes between {base_branch} and {head_branch}")
125
+ ctx.textual.end_step("success")
126
+ return Success("No changes")
127
+
128
+ # Show the stat summary with colors
129
+ ctx.textual.text("") # spacing
130
+ ctx.textual.bold_text(f"Changes in {head_branch} vs {base_branch}:")
131
+ ctx.textual.text("") # spacing
132
+
133
+ # Parse lines to find max filename length for alignment
134
+ file_lines = []
135
+ summary_lines = []
136
+ max_filename_len = 0
137
+
138
+ for line in stat_output.split('\n'):
139
+ if not line.strip():
140
+ continue
141
+
142
+ if '|' in line:
143
+ parts = line.split('|')
144
+ filename = parts[0].strip()
145
+ stats = '|'.join(parts[1:]) if len(parts) > 1 else ''
146
+ file_lines.append((filename, stats))
147
+ max_filename_len = max(max_filename_len, len(filename))
148
+ else:
149
+ summary_lines.append(line)
150
+
151
+ # Display aligned file changes
152
+ for filename, stats in file_lines:
153
+ # Pad filename to align pipes
154
+ padded_filename = filename.ljust(max_filename_len)
155
+
156
+ # Replace + with green and - with red
157
+ stats = stats.replace('+', '[green]+[/green]')
158
+ stats = stats.replace('-', '[red]-[/red]')
159
+
160
+ ctx.textual.text(f" {padded_filename} | {stats}")
161
+
162
+ # Display summary lines
163
+ for line in summary_lines:
164
+ colored_line = line.replace('(+)', '[green](+)[/green]')
165
+ colored_line = colored_line.replace('(-)', '[red](-)[/red]')
166
+ ctx.textual.dim_text(f" {colored_line}")
167
+
168
+ ctx.textual.text("") # spacing
169
+
170
+ ctx.textual.end_step("success")
171
+ return Success("Branch diff summary displayed")
172
+
173
+ except Exception as e:
174
+ # Don't fail the workflow, just skip
175
+ ctx.textual.warning_text(f"Could not show branch diff summary: {e}")
176
+ ctx.textual.end_step("skip")
177
+ return Skip(f"Could not show branch diff summary: {e}")
178
+
179
+
180
+ __all__ = ["show_uncommitted_diff_summary", "show_branch_diff_summary"]
@@ -0,0 +1,70 @@
1
+ """
2
+ Pull from Git remote.
3
+ """
4
+
5
+ from typing import Optional
6
+ from titan_cli.engine import WorkflowContext, WorkflowResult, Success, Error
7
+ from titan_plugin_git.exceptions import GitError
8
+
9
+
10
+ def pull_step(ctx: WorkflowContext) -> WorkflowResult:
11
+ """
12
+ Pull from Git remote.
13
+
14
+ Inputs (from ctx.data):
15
+ remote (str, optional): Remote name (defaults to "origin")
16
+ branch (str, optional): Branch name (defaults to current branch)
17
+
18
+ Returns:
19
+ Success: Pull completed successfully
20
+ Error: Git operation failed
21
+ """
22
+ if ctx.textual:
23
+ ctx.textual.begin_step("Pull from Remote")
24
+
25
+ if not ctx.textual:
26
+ return Error("Textual UI context is not available for this step.")
27
+
28
+ if not ctx.git:
29
+ ctx.textual.error_text("Git client not available in context")
30
+ ctx.textual.end_step("error")
31
+ return Error("Git client not available in context")
32
+
33
+ try:
34
+ # Get params from context (optional)
35
+ remote = ctx.get("remote", "origin")
36
+ branch: Optional[str] = ctx.get("pull_branch") # Optional, defaults to current
37
+
38
+ ctx.textual.text("")
39
+ if branch:
40
+ ctx.textual.dim_text(f"Pulling {remote}/{branch}...")
41
+ else:
42
+ ctx.textual.dim_text(f"Pulling from {remote}...")
43
+
44
+ # Pull
45
+ try:
46
+ with ctx.textual.loading("Pulling from remote..."):
47
+ ctx.git.pull(remote=remote, branch=branch)
48
+ ctx.textual.success_text(f"✓ Pulled from {remote}")
49
+ except GitError as e:
50
+ ctx.textual.text("")
51
+ ctx.textual.error_text(f"Failed to pull: {str(e)}")
52
+ ctx.textual.end_step("error")
53
+ return Error(f"Failed to pull: {str(e)}")
54
+
55
+ ctx.textual.text("")
56
+ ctx.textual.end_step("success")
57
+ return Success(
58
+ f"Pulled from {remote}",
59
+ metadata={"remote": remote, "branch": branch}
60
+ )
61
+
62
+ except Exception as e:
63
+ ctx.textual.text("")
64
+ ctx.textual.error_text(f"Failed to pull: {str(e)}")
65
+ ctx.textual.text("")
66
+ ctx.textual.end_step("error")
67
+ return Error(f"Failed to pull: {str(e)}")
68
+
69
+
70
+ __all__ = ["pull_step"]
@@ -2,7 +2,6 @@
2
2
  from titan_cli.engine import WorkflowContext, WorkflowResult, Success, Error
3
3
  from titan_plugin_git.exceptions import GitCommandError
4
4
  from titan_plugin_git.messages import msg
5
- from titan_cli.ui.tui.widgets import Panel
6
5
 
7
6
  def create_git_push_step(ctx: WorkflowContext) -> WorkflowResult:
8
7
  """
@@ -12,6 +11,7 @@ def create_git_push_step(ctx: WorkflowContext) -> WorkflowResult:
12
11
  remote (str, optional): The name of the remote to push to. Defaults to the client's default remote.
13
12
  branch (str, optional): The name of the branch to push. Defaults to the current branch.
14
13
  set_upstream (bool, optional): Whether to set the upstream tracking branch. Defaults to False.
14
+ push_tags (bool, optional): Whether to push tags along with the branch. Defaults to False.
15
15
 
16
16
  Requires:
17
17
  ctx.git: An initialized GitClient.
@@ -29,10 +29,14 @@ def create_git_push_step(ctx: WorkflowContext) -> WorkflowResult:
29
29
  if not ctx.git:
30
30
  return Error(msg.Steps.Push.GIT_CLIENT_NOT_AVAILABLE)
31
31
 
32
+ # Begin step container
33
+ ctx.textual.begin_step("Push changes to remote")
34
+
32
35
  # Get params from context
33
36
  remote = ctx.get('remote')
34
37
  branch = ctx.get('branch')
35
38
  set_upstream = ctx.get('set_upstream', False)
39
+ push_tags = ctx.get('push_tags', False)
36
40
 
37
41
  # Use defaults from the GitClient if not provided in the context
38
42
  remote_to_use = remote or ctx.git.default_remote
@@ -43,21 +47,33 @@ def create_git_push_step(ctx: WorkflowContext) -> WorkflowResult:
43
47
  if not ctx.git.branch_exists_on_remote(branch=branch_to_use, remote=remote_to_use):
44
48
  set_upstream = True
45
49
 
46
- ctx.git.push(remote=remote_to_use, branch=branch_to_use, set_upstream=set_upstream)
47
-
48
- # Show success panel
49
- ctx.textual.mount(
50
- Panel(
51
- text=f"Pushed to {remote_to_use}/{branch_to_use}",
52
- panel_type="success"
53
- )
50
+ # Push branch (and tags if requested)
51
+ ctx.git.push(
52
+ remote=remote_to_use,
53
+ branch=branch_to_use,
54
+ set_upstream=set_upstream,
55
+ tags=push_tags
54
56
  )
55
57
 
58
+ # Show success message
59
+ success_msg = f"Pushed to {remote_to_use}/{branch_to_use}"
60
+ if push_tags:
61
+ success_msg += " (with tags)"
62
+
63
+ ctx.textual.success_text(success_msg)
64
+
65
+ ctx.textual.end_step("success")
56
66
  return Success(
57
67
  message=msg.Git.PUSH_SUCCESS.format(remote=remote_to_use, branch=branch_to_use),
58
68
  metadata={"pr_head_branch": branch_to_use}
59
69
  )
60
70
  except GitCommandError as e:
61
- return Error(msg.Steps.Push.PUSH_FAILED.format(e=e))
71
+ error_msg = msg.Steps.Push.PUSH_FAILED.format(e=e)
72
+ ctx.textual.error_text(error_msg)
73
+ ctx.textual.end_step("error")
74
+ return Error(error_msg)
62
75
  except Exception as e:
63
- return Error(msg.Git.UNEXPECTED_ERROR.format(e=e))
76
+ error_msg = msg.Git.UNEXPECTED_ERROR.format(e=e)
77
+ ctx.textual.error_text(error_msg)
78
+ ctx.textual.end_step("error")
79
+ return Error(error_msg)
@@ -0,0 +1,97 @@
1
+ """
2
+ Restore original branch and pop stashed changes.
3
+ """
4
+
5
+ from titan_cli.engine import WorkflowContext, WorkflowResult, Success, Error
6
+ from titan_plugin_git.exceptions import GitError
7
+
8
+
9
+ def restore_original_branch_step(ctx: WorkflowContext) -> WorkflowResult:
10
+ """
11
+ Restore original branch and pop stashed changes.
12
+
13
+ Returns the user to their original branch and restores any changes
14
+ that were stashed at the beginning of the workflow.
15
+
16
+ This step ALWAYS executes, even if the workflow failed.
17
+
18
+ Inputs (from ctx.data):
19
+ original_branch (str): Name of the branch to restore
20
+ has_stashed_changes (bool): Whether to pop stashed changes
21
+
22
+ Returns:
23
+ Success: Branch restored and changes popped if needed
24
+ Error: Git operation failed
25
+ """
26
+ if ctx.textual:
27
+ ctx.textual.begin_step("Restore Original Branch")
28
+
29
+ if not ctx.textual:
30
+ return Error("Textual UI context is not available for this step.")
31
+
32
+ if not ctx.git:
33
+ ctx.textual.error_text("Git client not available in context")
34
+ ctx.textual.end_step("error")
35
+ return Error("Git client not available in context")
36
+
37
+ try:
38
+ # Get original branch from context
39
+ original_branch = ctx.get("original_branch")
40
+ has_stashed_changes = ctx.get("has_stashed_changes", False)
41
+
42
+ if not original_branch:
43
+ ctx.textual.warning_text("No original branch to restore")
44
+ ctx.textual.end_step("success")
45
+ return Success("No branch to restore")
46
+
47
+ ctx.textual.text("")
48
+ ctx.textual.dim_text(f"Returning to: {original_branch}")
49
+
50
+ # Checkout original branch
51
+ try:
52
+ ctx.git.checkout(original_branch)
53
+ ctx.textual.success_text(f"✓ Checked out {original_branch}")
54
+ except GitError as e:
55
+ ctx.textual.error_text(f"Failed to checkout {original_branch}: {str(e)}")
56
+ ctx.textual.end_step("error")
57
+ return Error(f"Failed to checkout: {str(e)}")
58
+
59
+ # Pop stashed changes if any
60
+ if has_stashed_changes:
61
+ ctx.textual.text("")
62
+ ctx.textual.dim_text("Restoring stashed changes...")
63
+
64
+ with ctx.textual.loading("Popping stashed changes..."):
65
+ success = ctx.git.stash_pop()
66
+
67
+ if not success:
68
+ ctx.textual.warning_text("Failed to pop stash automatically")
69
+ ctx.textual.dim_text("Run: git stash pop")
70
+ # Don't fail the step, just warn
71
+ else:
72
+ ctx.textual.success_text("✓ Changes restored")
73
+
74
+ ctx.textual.text("")
75
+ ctx.textual.end_step("success")
76
+ return Success(
77
+ f"Restored to {original_branch}",
78
+ metadata={
79
+ "original_branch": original_branch,
80
+ "stash_popped": has_stashed_changes
81
+ }
82
+ )
83
+
84
+ except Exception as e:
85
+ ctx.textual.text("")
86
+ ctx.textual.error_text(f"Failed to restore original branch: {str(e)}")
87
+ ctx.textual.dim_text("You may need to manually restore your branch:")
88
+ if ctx.get("original_branch"):
89
+ ctx.textual.dim_text(f" git checkout {ctx.get('original_branch')}")
90
+ if ctx.get("has_stashed_changes"):
91
+ ctx.textual.dim_text(" git stash pop")
92
+ ctx.textual.text("")
93
+ ctx.textual.end_step("error")
94
+ return Error(f"Failed to restore: {str(e)}")
95
+
96
+
97
+ __all__ = ["restore_original_branch_step"]
@@ -0,0 +1,82 @@
1
+ """
2
+ Save current branch and stash uncommitted changes.
3
+ """
4
+
5
+ from titan_cli.engine import WorkflowContext, WorkflowResult, Success, Error
6
+
7
+
8
+ def save_current_branch_step(ctx: WorkflowContext) -> WorkflowResult:
9
+ """
10
+ Save current branch and stash uncommitted changes.
11
+
12
+ This allows the workflow to create release notes in a separate branch
13
+ without affecting the user's current work.
14
+
15
+ Outputs (saved to ctx.data):
16
+ original_branch (str): Name of the branch the user was on
17
+ has_stashed_changes (bool): Whether changes were stashed
18
+
19
+ Returns:
20
+ Success: Branch saved and changes stashed if needed
21
+ Error: Git operation failed
22
+ """
23
+ if ctx.textual:
24
+ ctx.textual.begin_step("Save Current Branch")
25
+
26
+ if not ctx.textual:
27
+ return Error("Textual UI context is not available for this step.")
28
+
29
+ if not ctx.git:
30
+ ctx.textual.error_text("Git client not available in context")
31
+ ctx.textual.end_step("error")
32
+ return Error("Git client not available in context")
33
+
34
+ try:
35
+ # Get current branch
36
+ current_branch = ctx.git.get_current_branch()
37
+ ctx.set("original_branch", current_branch)
38
+
39
+ ctx.textual.text("")
40
+ ctx.textual.dim_text(f"Current branch: {current_branch}")
41
+
42
+ # Check for uncommitted changes
43
+ has_changes = ctx.git.has_uncommitted_changes()
44
+
45
+ if has_changes:
46
+ ctx.textual.dim_text("Uncommitted changes detected")
47
+ ctx.textual.text("")
48
+
49
+ # Stash changes
50
+ with ctx.textual.loading("Stashing uncommitted changes..."):
51
+ success = ctx.git.stash_push(message="titan-release-notes-workflow")
52
+
53
+ if not success:
54
+ ctx.textual.error_text("Failed to stash changes")
55
+ ctx.textual.end_step("error")
56
+ return Error("Failed to stash changes")
57
+
58
+ ctx.set("has_stashed_changes", True)
59
+ ctx.textual.success_text("✓ Changes stashed successfully")
60
+ else:
61
+ ctx.textual.dim_text("No uncommitted changes")
62
+ ctx.set("has_stashed_changes", False)
63
+
64
+ ctx.textual.text("")
65
+ ctx.textual.end_step("success")
66
+ return Success(
67
+ f"Saved branch: {current_branch}",
68
+ metadata={
69
+ "original_branch": current_branch,
70
+ "has_stashed_changes": has_changes
71
+ }
72
+ )
73
+
74
+ except Exception as e:
75
+ ctx.textual.text("")
76
+ ctx.textual.error_text(f"Failed to save current branch: {str(e)}")
77
+ ctx.textual.text("")
78
+ ctx.textual.end_step("error")
79
+ return Error(f"Failed to save current branch: {str(e)}")
80
+
81
+
82
+ __all__ = ["save_current_branch_step"]