titan-cli 0.1.5__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 (54) hide show
  1. titan_cli/core/workflows/project_step_source.py +52 -7
  2. titan_cli/core/workflows/workflow_filter_service.py +6 -4
  3. titan_cli/engine/__init__.py +5 -1
  4. titan_cli/engine/results.py +31 -1
  5. titan_cli/engine/steps/ai_assistant_step.py +18 -18
  6. titan_cli/engine/workflow_executor.py +7 -2
  7. titan_cli/ui/tui/screens/plugin_config_wizard.py +16 -0
  8. titan_cli/ui/tui/screens/workflow_execution.py +22 -24
  9. titan_cli/ui/tui/screens/workflows.py +8 -4
  10. titan_cli/ui/tui/textual_components.py +293 -189
  11. titan_cli/ui/tui/textual_workflow_executor.py +30 -2
  12. titan_cli/ui/tui/theme.py +34 -5
  13. titan_cli/ui/tui/widgets/__init__.py +15 -0
  14. titan_cli/ui/tui/widgets/multiline_input.py +32 -0
  15. titan_cli/ui/tui/widgets/prompt_choice.py +138 -0
  16. titan_cli/ui/tui/widgets/prompt_input.py +74 -0
  17. titan_cli/ui/tui/widgets/prompt_selection_list.py +150 -0
  18. titan_cli/ui/tui/widgets/prompt_textarea.py +87 -0
  19. titan_cli/ui/tui/widgets/styled_option_list.py +107 -0
  20. titan_cli/ui/tui/widgets/text.py +51 -130
  21. {titan_cli-0.1.5.dist-info → titan_cli-0.1.6.dist-info}/METADATA +5 -10
  22. {titan_cli-0.1.5.dist-info → titan_cli-0.1.6.dist-info}/RECORD +54 -41
  23. {titan_cli-0.1.5.dist-info → titan_cli-0.1.6.dist-info}/WHEEL +1 -1
  24. titan_plugin_git/clients/git_client.py +59 -2
  25. titan_plugin_git/plugin.py +10 -0
  26. titan_plugin_git/steps/ai_commit_message_step.py +8 -8
  27. titan_plugin_git/steps/branch_steps.py +6 -6
  28. titan_plugin_git/steps/checkout_step.py +66 -0
  29. titan_plugin_git/steps/commit_step.py +3 -3
  30. titan_plugin_git/steps/create_branch_step.py +131 -0
  31. titan_plugin_git/steps/diff_summary_step.py +11 -13
  32. titan_plugin_git/steps/pull_step.py +70 -0
  33. titan_plugin_git/steps/push_step.py +3 -3
  34. titan_plugin_git/steps/restore_original_branch_step.py +97 -0
  35. titan_plugin_git/steps/save_current_branch_step.py +82 -0
  36. titan_plugin_git/steps/status_step.py +23 -13
  37. titan_plugin_git/workflows/commit-ai.yaml +4 -3
  38. titan_plugin_github/steps/ai_pr_step.py +90 -22
  39. titan_plugin_github/steps/create_pr_step.py +8 -8
  40. titan_plugin_github/steps/github_prompt_steps.py +13 -13
  41. titan_plugin_github/steps/issue_steps.py +14 -15
  42. titan_plugin_github/steps/preview_step.py +8 -8
  43. titan_plugin_github/workflows/create-pr-ai.yaml +1 -11
  44. titan_plugin_jira/messages.py +12 -0
  45. titan_plugin_jira/plugin.py +4 -0
  46. titan_plugin_jira/steps/ai_analyze_issue_step.py +6 -6
  47. titan_plugin_jira/steps/get_issue_step.py +7 -7
  48. titan_plugin_jira/steps/list_versions_step.py +133 -0
  49. titan_plugin_jira/steps/prompt_select_issue_step.py +8 -9
  50. titan_plugin_jira/steps/search_jql_step.py +191 -0
  51. titan_plugin_jira/steps/search_saved_query_step.py +13 -13
  52. titan_plugin_jira/utils/__init__.py +1 -1
  53. {titan_cli-0.1.5.dist-info/licenses → titan_cli-0.1.6.dist-info}/LICENSE +0 -0
  54. {titan_cli-0.1.5.dist-info → titan_cli-0.1.6.dist-info}/entry_points.txt +0 -0
@@ -0,0 +1,66 @@
1
+ """
2
+ Checkout a 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 checkout_branch_step(ctx: WorkflowContext) -> WorkflowResult:
10
+ """
11
+ Checkout a Git branch.
12
+
13
+ Inputs (from ctx.data):
14
+ branch (str): Branch name to checkout
15
+
16
+ Returns:
17
+ Success: Branch checked out successfully
18
+ Error: Git operation failed
19
+ """
20
+ if ctx.textual:
21
+ ctx.textual.begin_step("Checkout Branch")
22
+
23
+ if not ctx.textual:
24
+ return Error("Textual UI context is not available for this step.")
25
+
26
+ if not ctx.git:
27
+ ctx.textual.error_text("Git client not available in context")
28
+ ctx.textual.end_step("error")
29
+ return Error("Git client not available in context")
30
+
31
+ try:
32
+ # Get branch from params, or use main_branch from git config
33
+ branch = ctx.get("branch")
34
+ if not branch:
35
+ branch = ctx.git.main_branch
36
+ ctx.textual.dim_text(f"Using main branch from config: {branch}")
37
+
38
+ ctx.textual.text("")
39
+ ctx.textual.dim_text(f"Checking out: {branch}")
40
+
41
+ # Checkout branch
42
+ try:
43
+ ctx.git.checkout(branch)
44
+ ctx.textual.success_text(f"✓ Checked out {branch}")
45
+ except GitError as e:
46
+ ctx.textual.text("")
47
+ ctx.textual.error_text(f"Failed to checkout {branch}: {str(e)}")
48
+ ctx.textual.end_step("error")
49
+ return Error(f"Failed to checkout: {str(e)}")
50
+
51
+ ctx.textual.text("")
52
+ ctx.textual.end_step("success")
53
+ return Success(
54
+ f"Checked out {branch}",
55
+ metadata={"branch": branch}
56
+ )
57
+
58
+ except Exception as e:
59
+ ctx.textual.text("")
60
+ ctx.textual.error_text(f"Failed to checkout branch: {str(e)}")
61
+ ctx.textual.text("")
62
+ ctx.textual.end_step("error")
63
+ return Error(f"Failed to checkout: {str(e)}")
64
+
65
+
66
+ __all__ = ["checkout_branch_step"]
@@ -37,7 +37,7 @@ def create_git_commit_step(ctx: WorkflowContext) -> WorkflowResult:
37
37
  # Skip if there's nothing to commit
38
38
  git_status = ctx.data.get("git_status")
39
39
  if git_status and git_status.is_clean:
40
- ctx.textual.text(msg.Steps.Commit.WORKING_DIRECTORY_CLEAN, markup="dim")
40
+ ctx.textual.dim_text(msg.Steps.Commit.WORKING_DIRECTORY_CLEAN)
41
41
  ctx.textual.end_step("skip")
42
42
  return Skip(msg.Steps.Commit.WORKING_DIRECTORY_CLEAN)
43
43
 
@@ -47,7 +47,7 @@ def create_git_commit_step(ctx: WorkflowContext) -> WorkflowResult:
47
47
 
48
48
  commit_message = ctx.get('commit_message')
49
49
  if not commit_message:
50
- ctx.textual.text(msg.Steps.Commit.NO_COMMIT_MESSAGE, markup="dim")
50
+ ctx.textual.dim_text(msg.Steps.Commit.NO_COMMIT_MESSAGE)
51
51
  ctx.textual.end_step("skip")
52
52
  return Skip(msg.Steps.Commit.NO_COMMIT_MESSAGE)
53
53
 
@@ -58,7 +58,7 @@ def create_git_commit_step(ctx: WorkflowContext) -> WorkflowResult:
58
58
  commit_hash = ctx.git.commit(message=commit_message, all=all_files, no_verify=no_verify)
59
59
 
60
60
  # Show success message
61
- ctx.textual.text(f"Commit created: {commit_hash[:7]}", markup="green")
61
+ ctx.textual.success_text(f"Commit created: {commit_hash[:7]}")
62
62
 
63
63
  ctx.textual.end_step("success")
64
64
  return Success(
@@ -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"]
@@ -24,16 +24,16 @@ def show_uncommitted_diff_summary(ctx: WorkflowContext) -> WorkflowResult:
24
24
 
25
25
  try:
26
26
  # Get diff stat for uncommitted changes
27
- stat_output = ctx.git._run_command(["git", "diff", "--stat", "HEAD"])
27
+ stat_output = ctx.git.get_uncommitted_diff_stat()
28
28
 
29
29
  if not stat_output or not stat_output.strip():
30
- ctx.textual.text("No uncommitted changes to show", markup="dim")
30
+ ctx.textual.dim_text("No uncommitted changes to show")
31
31
  ctx.textual.end_step("success")
32
32
  return Success("No changes")
33
33
 
34
34
  # Show the stat summary with colors
35
35
  ctx.textual.text("") # spacing
36
- ctx.textual.text("Changes summary:", markup="bold")
36
+ ctx.textual.bold_text("Changes summary:")
37
37
  ctx.textual.text("") # spacing
38
38
 
39
39
  # Parse lines to find max filename length for alignment
@@ -69,7 +69,7 @@ def show_uncommitted_diff_summary(ctx: WorkflowContext) -> WorkflowResult:
69
69
  for line in summary_lines:
70
70
  colored_line = line.replace('(+)', '[green](+)[/green]')
71
71
  colored_line = colored_line.replace('(-)', '[red](-)[/red]')
72
- ctx.textual.text(f" {colored_line}", markup="dim")
72
+ ctx.textual.dim_text(f" {colored_line}")
73
73
 
74
74
  ctx.textual.text("") # spacing
75
75
 
@@ -104,13 +104,13 @@ def show_branch_diff_summary(ctx: WorkflowContext) -> WorkflowResult:
104
104
  ctx.textual.begin_step("Show Branch Changes Summary")
105
105
 
106
106
  if not ctx.git:
107
- ctx.textual.text(msg.Steps.Push.GIT_CLIENT_NOT_AVAILABLE, markup="red")
107
+ ctx.textual.error_text(msg.Steps.Push.GIT_CLIENT_NOT_AVAILABLE)
108
108
  ctx.textual.end_step("error")
109
109
  return Error(msg.Steps.Push.GIT_CLIENT_NOT_AVAILABLE)
110
110
 
111
111
  head_branch = ctx.get("pr_head_branch")
112
112
  if not head_branch:
113
- ctx.textual.text("No head branch specified", markup="dim")
113
+ ctx.textual.dim_text("No head branch specified")
114
114
  ctx.textual.end_step("skip")
115
115
  return Skip("No head branch specified")
116
116
 
@@ -118,18 +118,16 @@ def show_branch_diff_summary(ctx: WorkflowContext) -> WorkflowResult:
118
118
 
119
119
  try:
120
120
  # Get diff stat between branches
121
- stat_output = ctx.git._run_command([
122
- "git", "diff", "--stat", f"{base_branch}...{head_branch}"
123
- ])
121
+ stat_output = ctx.git.get_branch_diff_stat(base_branch, head_branch)
124
122
 
125
123
  if not stat_output or not stat_output.strip():
126
- ctx.textual.text(f"No changes between {base_branch} and {head_branch}", markup="dim")
124
+ ctx.textual.dim_text(f"No changes between {base_branch} and {head_branch}")
127
125
  ctx.textual.end_step("success")
128
126
  return Success("No changes")
129
127
 
130
128
  # Show the stat summary with colors
131
129
  ctx.textual.text("") # spacing
132
- ctx.textual.text(f"Changes in {head_branch} vs {base_branch}:", markup="bold")
130
+ ctx.textual.bold_text(f"Changes in {head_branch} vs {base_branch}:")
133
131
  ctx.textual.text("") # spacing
134
132
 
135
133
  # Parse lines to find max filename length for alignment
@@ -165,7 +163,7 @@ def show_branch_diff_summary(ctx: WorkflowContext) -> WorkflowResult:
165
163
  for line in summary_lines:
166
164
  colored_line = line.replace('(+)', '[green](+)[/green]')
167
165
  colored_line = colored_line.replace('(-)', '[red](-)[/red]')
168
- ctx.textual.text(f" {colored_line}", markup="dim")
166
+ ctx.textual.dim_text(f" {colored_line}")
169
167
 
170
168
  ctx.textual.text("") # spacing
171
169
 
@@ -174,7 +172,7 @@ def show_branch_diff_summary(ctx: WorkflowContext) -> WorkflowResult:
174
172
 
175
173
  except Exception as e:
176
174
  # Don't fail the workflow, just skip
177
- ctx.textual.text(f"Could not show branch diff summary: {e}", markup="yellow")
175
+ ctx.textual.warning_text(f"Could not show branch diff summary: {e}")
178
176
  ctx.textual.end_step("skip")
179
177
  return Skip(f"Could not show branch diff summary: {e}")
180
178
 
@@ -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"]
@@ -60,7 +60,7 @@ def create_git_push_step(ctx: WorkflowContext) -> WorkflowResult:
60
60
  if push_tags:
61
61
  success_msg += " (with tags)"
62
62
 
63
- ctx.textual.text(success_msg, markup="green")
63
+ ctx.textual.success_text(success_msg)
64
64
 
65
65
  ctx.textual.end_step("success")
66
66
  return Success(
@@ -69,11 +69,11 @@ def create_git_push_step(ctx: WorkflowContext) -> WorkflowResult:
69
69
  )
70
70
  except GitCommandError as e:
71
71
  error_msg = msg.Steps.Push.PUSH_FAILED.format(e=e)
72
- ctx.textual.text(error_msg, markup="red")
72
+ ctx.textual.error_text(error_msg)
73
73
  ctx.textual.end_step("error")
74
74
  return Error(error_msg)
75
75
  except Exception as e:
76
76
  error_msg = msg.Git.UNEXPECTED_ERROR.format(e=e)
77
- ctx.textual.text(error_msg, markup="red")
77
+ ctx.textual.error_text(error_msg)
78
78
  ctx.textual.end_step("error")
79
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"]
@@ -3,7 +3,8 @@ from titan_cli.engine import (
3
3
  WorkflowContext,
4
4
  WorkflowResult,
5
5
  Success,
6
- Error
6
+ Error,
7
+ Exit
7
8
  )
8
9
  from titan_cli.messages import msg as global_msg
9
10
  from ..messages import msg
@@ -12,6 +13,10 @@ def get_git_status_step(ctx: WorkflowContext) -> WorkflowResult:
12
13
  """
13
14
  Retrieves the current git status and saves it to the context.
14
15
 
16
+ Behavior:
17
+ - If there are uncommitted changes: Returns Success and continues workflow
18
+ - If working directory is clean: Returns Exit (stops workflow - nothing to commit)
19
+
15
20
  Requires:
16
21
  ctx.git: An initialized GitClient.
17
22
 
@@ -19,7 +24,8 @@ def get_git_status_step(ctx: WorkflowContext) -> WorkflowResult:
19
24
  git_status (GitStatus): The full git status object, which includes the `is_clean` flag.
20
25
 
21
26
  Returns:
22
- Success: If the status was retrieved successfully.
27
+ Success: If there are changes to commit (workflow continues)
28
+ Exit: If working directory is clean (workflow stops - nothing to commit)
23
29
  Error: If the GitClient is not available or the git command fails.
24
30
  """
25
31
  if not ctx.textual:
@@ -34,22 +40,26 @@ def get_git_status_step(ctx: WorkflowContext) -> WorkflowResult:
34
40
  try:
35
41
  status = ctx.git.get_status()
36
42
 
37
- # If there are uncommitted changes, show text (border will be green on success)
43
+ # If there are uncommitted changes, continue with workflow
38
44
  if not status.is_clean:
39
- ctx.textual.text(global_msg.Workflow.UNCOMMITTED_CHANGES_WARNING, markup="yellow")
45
+ ctx.textual.warning_text(global_msg.Workflow.UNCOMMITTED_CHANGES_WARNING)
40
46
  message = msg.Steps.Status.STATUS_RETRIEVED_WITH_UNCOMMITTED
47
+ ctx.textual.end_step("success")
48
+
49
+ return Success(
50
+ message=message,
51
+ metadata={"git_status": status}
52
+ )
41
53
  else:
42
- # Show text for clean working directory (border will be green)
43
- ctx.textual.text(msg.Steps.Status.WORKING_DIRECTORY_IS_CLEAN, markup="green")
44
- message = msg.Steps.Status.WORKING_DIRECTORY_IS_CLEAN
54
+ # Working directory is clean - exit workflow (nothing to commit)
55
+ ctx.textual.success_text(msg.Steps.Status.WORKING_DIRECTORY_IS_CLEAN)
56
+ ctx.textual.text("")
57
+ ctx.textual.dim_text("Nothing to commit. Skipping workflow.")
58
+ ctx.textual.end_step("success")
45
59
 
46
- # End step container with success
47
- ctx.textual.end_step("success")
60
+ # Exit workflow early (not an error)
61
+ return Exit("No changes to commit", metadata={"git_status": status})
48
62
 
49
- return Success(
50
- message=message,
51
- metadata={"git_status": status}
52
- )
53
63
  except Exception as e:
54
64
  # End step container with error
55
65
  ctx.textual.end_step("error")
@@ -5,13 +5,14 @@ hooks:
5
5
  - before_commit # Hook for linting/testing
6
6
 
7
7
  steps:
8
- - hook: before_commit # This is where injected steps will run
9
-
8
+ # Check for changes FIRST - exits workflow if nothing to commit
10
9
  - id: git_status
11
10
  name: "Check Git Status"
12
11
  plugin: git
13
12
  step: get_status
14
13
 
14
+ - hook: before_commit # This is where injected steps will run
15
+
15
16
  - id: diff_summary
16
17
  name: "Show Changes Summary"
17
18
  plugin: git
@@ -26,7 +27,7 @@ steps:
26
27
  name: "Create Commit"
27
28
  plugin: git
28
29
  step: create_commit
29
-
30
+
30
31
  - id: push
31
32
  name: "Push changes to remote"
32
33
  plugin: git