datasecops-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.
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
@@ -0,0 +1,100 @@
1
+ from pathlib import Path
2
+ from typing import Optional
3
+
4
+ from datasecops_cli.models.project_config import (
5
+ DatasecopsConfig, ProjectSettings, SourceControl, ProjectProfile, DbtTarget
6
+ )
7
+ from datasecops_cli.utilities.yaml_utils import read_datasecops_config, read_dbt_project
8
+ from datasecops_cli.utilities.display import error_line, info_line
9
+
10
+
11
+ class Config:
12
+ """Central configuration for the CLI, loaded from .datasecops.yml and native app."""
13
+
14
+ def __init__(self):
15
+ self.datasecops: DatasecopsConfig = DatasecopsConfig()
16
+ self.project_settings: ProjectSettings = ProjectSettings()
17
+ self.source_control: SourceControl = SourceControl()
18
+ self.profile: Optional[ProjectProfile] = None
19
+ self.all_profiles: list[ProjectProfile] = []
20
+ self.project_dir: Path = Path.cwd()
21
+ self.dbt_project_dir: Path = Path.cwd()
22
+ self.profile_name: str = ""
23
+
24
+ def load(self, project_dir: Path = None) -> bool:
25
+ """Load configuration from .datasecops.yml and dbt_project.yml."""
26
+ self.project_dir = project_dir or Path.cwd()
27
+
28
+ # Load .datasecops.yml
29
+ raw = read_datasecops_config(self.project_dir)
30
+ if raw is None:
31
+ error_line("No .datasecops.yml found. Run setup.sh/setup.ps1 first.")
32
+ return False
33
+
34
+ self.datasecops = DatasecopsConfig(
35
+ connection_name=raw.get("connection_name", ""),
36
+ app_database=raw.get("app_database", ""),
37
+ profile_name=raw.get("profile_name", ""),
38
+ )
39
+
40
+ if not self.datasecops.connection_name or not self.datasecops.app_database:
41
+ error_line(".datasecops.yml is missing connection_name or app_database.")
42
+ return False
43
+
44
+ # Load dbt_project.yml to get profile name
45
+ dbt_data = read_dbt_project(self.project_dir)
46
+ if dbt_data:
47
+ self.profile_name = dbt_data.get("profile", self.datasecops.profile_name)
48
+ else:
49
+ self.profile_name = self.datasecops.profile_name
50
+
51
+ # Resolve dbt project directory
52
+ for candidate in [self.project_dir / "dbt", self.project_dir]:
53
+ if (candidate / "dbt_project.yml").exists():
54
+ self.dbt_project_dir = candidate
55
+ break
56
+
57
+ return True
58
+
59
+ def load_from_native_app(self, snowflake_service) -> bool:
60
+ """Load configuration from the native app database."""
61
+ try:
62
+ # Load project settings
63
+ raw = snowflake_service.get_framework_config("PROJECT")
64
+ if raw:
65
+ self.project_settings = ProjectSettings(**{k: v for k, v in raw.items() if k in ProjectSettings.model_fields})
66
+
67
+ # Load source control
68
+ raw = snowflake_service.get_framework_config("SOURCE_CONTROL")
69
+ if raw:
70
+ self.source_control = SourceControl(**{k: v for k, v in raw.items() if k in SourceControl.model_fields})
71
+
72
+ # Load project profiles
73
+ profiles_data = snowflake_service.get_project_profiles()
74
+ if profiles_data:
75
+ self.all_profiles = [ProjectProfile(**{k.lower(): v for k, v in p.items()}) for p in profiles_data]
76
+
77
+ # Find the active profile
78
+ if self.profile_name:
79
+ for p in self.all_profiles:
80
+ if p.profile_name == self.profile_name:
81
+ self.profile = p
82
+ break
83
+
84
+ if not self.profile and self.all_profiles:
85
+ self.profile = self.all_profiles[0]
86
+ self.profile_name = self.profile.profile_name
87
+
88
+ return True
89
+ except Exception as e:
90
+ error_line(f"Failed to load from native app: {e}")
91
+ return False
92
+
93
+ def get_dbt_profiles_dir(self) -> Path:
94
+ return Path(self.project_settings.profile_dir).expanduser()
95
+
96
+ def get_default_target(self) -> Optional[DbtTarget]:
97
+ return self.project_settings.get_default_target()
98
+
99
+ def get_deployment_branches(self) -> dict[str, str]:
100
+ return {b: b for b in self.project_settings.get_deployment_branches()}
datasecops_cli/main.py ADDED
@@ -0,0 +1,122 @@
1
+ import sys
2
+ from pathlib import Path
3
+
4
+ from datasecops_cli.config import Config
5
+ from datasecops_cli.services.snowflake_service import SnowflakeService
6
+ from datasecops_cli.services.dbt_runner import DbtRunner
7
+ from datasecops_cli.services.git_service import GitService
8
+ from datasecops_cli.services.linting_service import LintingService
9
+ from datasecops_cli.services.download_service import DownloadService
10
+ from datasecops_cli.services.skill_service import SkillService
11
+ from datasecops_cli.menus.development import DevelopmentMenu
12
+ from datasecops_cli.menus.git_operations import GitOperationsMenu
13
+ from datasecops_cli.menus.downloads import DownloadsMenu
14
+ from datasecops_cli.utilities.display import (
15
+ clear, section_header, menu_option, get_input_number,
16
+ info_line, error_line, success_line
17
+ )
18
+
19
+
20
+ def main():
21
+ """Main entry point for the datasecops CLI."""
22
+ config = Config()
23
+
24
+ if not config.load():
25
+ sys.exit(1)
26
+
27
+ # Connect to Snowflake
28
+ sf_config = config.datasecops
29
+ sf_service = SnowflakeService(sf_config)
30
+
31
+ try:
32
+ info_line(f"Connecting to Snowflake ({sf_config.connection_name})...")
33
+ sf_service.connect()
34
+ success_line("Connected")
35
+ except Exception as e:
36
+ error_line(f"Failed to connect to Snowflake: {e}")
37
+ sys.exit(1)
38
+
39
+ try:
40
+ # Load config from native app
41
+ info_line("Loading framework configuration...")
42
+ config.load_from_native_app(sf_service)
43
+
44
+ if not config.profile:
45
+ error_line(f"Profile '{config.profile_name}' not found in native app")
46
+ sys.exit(1)
47
+
48
+ success_line(f"Profile: {config.profile.project_name} ({config.profile_name})")
49
+
50
+ # Initialize services
51
+ dbt_runner = DbtRunner(
52
+ project_dir=config.dbt_project_dir,
53
+ profiles_dir=config.get_dbt_profiles_dir(),
54
+ target=config.project_settings.get_default_target().target_name if config.get_default_target() else "dev"
55
+ )
56
+
57
+ try:
58
+ git_service = GitService(config.project_dir)
59
+ except Exception:
60
+ git_service = None
61
+
62
+ linting_service = LintingService(config.dbt_project_dir)
63
+ download_service = DownloadService(sf_service, config.project_dir)
64
+ skill_service = SkillService(sf_service)
65
+
66
+ # Main menu loop
67
+ _main_menu(config, dbt_runner, git_service, linting_service,
68
+ download_service, skill_service, sf_service)
69
+
70
+ finally:
71
+ sf_service.close()
72
+
73
+
74
+ def _main_menu(config: Config, dbt_runner: DbtRunner, git_service: GitService,
75
+ linting_service: LintingService, download_service: DownloadService,
76
+ skill_service: SkillService, sf_service: SnowflakeService):
77
+ """Main menu loop."""
78
+ profile_name = config.profile_name
79
+
80
+ _show_main_menu(profile_name, git_service)
81
+ option = get_input_number("Choose an option: ")
82
+
83
+ while option != 0:
84
+ if option == 1:
85
+ if git_service:
86
+ dev_menu = DevelopmentMenu(dbt_runner, linting_service, git_service, profile_name)
87
+ else:
88
+ dev_menu = DevelopmentMenu(dbt_runner, linting_service, None, profile_name)
89
+ dev_menu.show()
90
+
91
+ elif option == 2 and git_service:
92
+ git_menu = GitOperationsMenu(
93
+ git_service, config.source_control,
94
+ config.get_deployment_branches(), profile_name
95
+ )
96
+ git_menu.show()
97
+
98
+ elif option == 3:
99
+ dl_menu = DownloadsMenu(
100
+ download_service, skill_service, dbt_runner,
101
+ profile_name, config.dbt_project_dir
102
+ )
103
+ dl_menu.show()
104
+
105
+ _show_main_menu(profile_name, git_service)
106
+ option = get_input_number("Choose an option: ")
107
+
108
+ info_line("Exiting DataSecOps Framework CLI")
109
+
110
+
111
+ def _show_main_menu(profile_name: str, git_service: GitService = None):
112
+ clear()
113
+ branch = git_service.get_current_branch() if git_service else None
114
+ section_header("DataSecOps Framework CLI", profile_name, branch)
115
+ menu_option(1, "development - dbt Development Commands")
116
+ menu_option(2, "git - Source Control Operations")
117
+ menu_option(3, "downloads - Download Configs & Skills")
118
+ menu_option(0, "exit - Exit")
119
+
120
+
121
+ if __name__ == "__main__":
122
+ main()
@@ -0,0 +1 @@
1
+
@@ -0,0 +1,160 @@
1
+ from datasecops_cli.services.dbt_runner import DbtRunner
2
+ from datasecops_cli.services.linting_service import LintingService
3
+ from datasecops_cli.services.git_service import GitService
4
+ from datasecops_cli.utilities.display import (
5
+ clear, section_header, display_action_header, menu_option,
6
+ get_input_number, get_input_string, get_input_true_false, complete_action,
7
+ info_line, error_line
8
+ )
9
+
10
+
11
+ class DevelopmentMenu:
12
+ def __init__(self, dbt_runner: DbtRunner, linting_service: LintingService,
13
+ git_service: GitService, profile_name: str):
14
+ self.dbt = dbt_runner
15
+ self.linting = linting_service
16
+ self.git = git_service
17
+ self.profile_name = profile_name
18
+
19
+ def show(self) -> None:
20
+ self._menu()
21
+ option = get_input_number("Choose an option: ")
22
+ while option != 0:
23
+ if option == 1:
24
+ self._run_menu()
25
+ elif option == 2:
26
+ self._test_menu()
27
+ elif option == 3:
28
+ self._lint_menu()
29
+ elif option == 4:
30
+ display_action_header("dbt docs")
31
+ self.dbt.docs_generate()
32
+ if get_input_true_false("Serve docs?"):
33
+ self.dbt.docs_serve()
34
+ complete_action()
35
+ elif option == 5:
36
+ display_action_header("dbt seed")
37
+ self.dbt.seed()
38
+ complete_action()
39
+ elif option == 6:
40
+ display_action_header("dbt deps")
41
+ self.dbt.deps()
42
+ complete_action()
43
+ elif option == 7:
44
+ display_action_header("dbt compile")
45
+ self.dbt.compile()
46
+ complete_action()
47
+ elif option == 8:
48
+ display_action_header("dbt snapshot")
49
+ self.dbt.snapshot()
50
+ complete_action()
51
+ elif option == 9:
52
+ display_action_header("dbt source freshness")
53
+ self.dbt.source_freshness()
54
+ complete_action()
55
+ elif option == 10:
56
+ display_action_header("dbt clean")
57
+ self.dbt.clean()
58
+ complete_action()
59
+ elif option == 11:
60
+ display_action_header("dbt debug")
61
+ self.dbt.debug()
62
+ complete_action()
63
+ elif option == 12:
64
+ display_action_header("dbt list")
65
+ self.dbt.list_models()
66
+ complete_action()
67
+ elif option == 13:
68
+ display_action_header("dbt retry")
69
+ self.dbt.retry()
70
+ complete_action()
71
+ self._menu()
72
+ option = get_input_number("Choose an option: ")
73
+
74
+ def _menu(self) -> None:
75
+ clear()
76
+ section_header("Development Menu", self.profile_name, self.git.get_current_branch() if self.git else None)
77
+ menu_option(1, "run - Run dbt models")
78
+ menu_option(2, "test - Run dbt tests")
79
+ menu_option(3, "lint - SQLFluff linting")
80
+ menu_option(4, "docs - Generate & serve dbt docs")
81
+ menu_option(5, "seed - Load seed data")
82
+ menu_option(6, "deps - Install dbt packages")
83
+ menu_option(7, "compile - Compile dbt models")
84
+ menu_option(8, "snapshot - Run dbt snapshots")
85
+ menu_option(9, "freshness - Check source freshness")
86
+ menu_option(10, "clean - Clean dbt target")
87
+ menu_option(11, "debug - Debug dbt connection")
88
+ menu_option(12, "list - List dbt resources")
89
+ menu_option(13, "retry - Retry failed dbt run")
90
+ menu_option(0, "back - Return to main menu")
91
+
92
+ def _run_menu(self) -> None:
93
+ clear()
94
+ display_action_header("dbt Run Options")
95
+ menu_option(1, "full run - Run all models")
96
+ menu_option(2, "modified - Run modified models only (state:modified+)")
97
+ menu_option(3, "specific - Run specific model(s)")
98
+ menu_option(4, "macro - Run a dbt macro")
99
+ menu_option(5, "retry - Retry failed run")
100
+ menu_option(0, "back - Return to development menu")
101
+ option = get_input_number("Choose an option: ")
102
+ if option == 1:
103
+ full_refresh = get_input_true_false("Full refresh?", "n")
104
+ self.dbt.run(full_refresh=full_refresh)
105
+ elif option == 2:
106
+ full_refresh = get_input_true_false("Full refresh?", "n")
107
+ self.dbt.run(modified_only=True, full_refresh=full_refresh)
108
+ elif option == 3:
109
+ select = get_input_string("Enter model selector: ")
110
+ if select != "0":
111
+ self.dbt.run(select=select)
112
+ elif option == 4:
113
+ macro = get_input_string("Enter macro name: ")
114
+ if macro != "0":
115
+ args_str = get_input_string("Enter args (JSON or empty): ", allow_empty=True)
116
+ self.dbt.run_operation(macro, args_str if args_str else None)
117
+ elif option == 5:
118
+ self.dbt.retry()
119
+ complete_action()
120
+
121
+ def _test_menu(self) -> None:
122
+ clear()
123
+ display_action_header("dbt Test Options")
124
+ menu_option(1, "all tests - Run all tests")
125
+ menu_option(2, "specific - Run specific test(s)")
126
+ menu_option(0, "back - Return to development menu")
127
+ option = get_input_number("Choose an option: ")
128
+ if option == 1:
129
+ self.dbt.test()
130
+ elif option == 2:
131
+ select = get_input_string("Enter test selector: ")
132
+ if select != "0":
133
+ self.dbt.test(select=select)
134
+ complete_action()
135
+
136
+ def _lint_menu(self) -> None:
137
+ clear()
138
+ display_action_header("SQLFluff Linting Options")
139
+ menu_option(1, "find modified - Lint modified SQL files")
140
+ menu_option(2, "fix modified - Fix modified SQL files")
141
+ menu_option(3, "find all - Lint all SQL files")
142
+ menu_option(4, "fix all - Fix all SQL files")
143
+ menu_option(5, "specific - Lint specific file")
144
+ menu_option(0, "back - Return to development menu")
145
+ option = get_input_number("Choose an option: ")
146
+ if option == 1:
147
+ changed = [f.file for f in self.git.get_changed_files()] if self.git else []
148
+ self.linting.lint_modified(fix=False, changed_files=changed)
149
+ elif option == 2:
150
+ changed = [f.file for f in self.git.get_changed_files()] if self.git else []
151
+ self.linting.lint_modified(fix=True, changed_files=changed)
152
+ elif option == 3:
153
+ self.linting.lint_file(fix=False)
154
+ elif option == 4:
155
+ self.linting.lint_file(fix=True)
156
+ elif option == 5:
157
+ path = get_input_string("Enter file path: ")
158
+ if path != "0":
159
+ self.linting.lint_file(file_path=path, fix=False)
160
+ complete_action()
@@ -0,0 +1,79 @@
1
+ from pathlib import Path
2
+
3
+ from datasecops_cli.services.download_service import DownloadService
4
+ from datasecops_cli.services.skill_service import SkillService
5
+ from datasecops_cli.services.dbt_runner import DbtRunner
6
+ from datasecops_cli.utilities.display import (
7
+ clear, section_header, display_action_header, menu_option,
8
+ get_input_number, get_input_true_false, complete_action,
9
+ info_line, select_from_list
10
+ )
11
+
12
+
13
+ class DownloadsMenu:
14
+ def __init__(self, download_service: DownloadService, skill_service: SkillService,
15
+ dbt_runner: DbtRunner, profile_name: str, dbt_project_dir: Path):
16
+ self.downloads = download_service
17
+ self.skills = skill_service
18
+ self.dbt = dbt_runner
19
+ self.profile_name = profile_name
20
+ self.dbt_project_dir = dbt_project_dir
21
+
22
+ def show(self) -> None:
23
+ self._menu()
24
+ option = get_input_number("Choose an option: ")
25
+ while option != 0:
26
+ if option == 1:
27
+ display_action_header("Download SQLFluff Config")
28
+ self.downloads.download_sqlfluff_config()
29
+ complete_action()
30
+ elif option == 2:
31
+ display_action_header("Download Pipeline Files")
32
+ platform = select_from_list(["github", "azuredevops"], "platform", add_back=False)
33
+ self.downloads.download_pipelines(platform=platform)
34
+ complete_action()
35
+ elif option == 3:
36
+ display_action_header("Download dbt Packages")
37
+ self.downloads.download_dbt_packages(self.dbt_project_dir)
38
+ if get_input_true_false("Run dbt deps now?"):
39
+ self.dbt.deps()
40
+ complete_action()
41
+ elif option == 4:
42
+ self._skills_menu()
43
+ self._menu()
44
+ option = get_input_number("Choose an option: ")
45
+
46
+ def _menu(self) -> None:
47
+ clear()
48
+ section_header("Downloads", self.profile_name)
49
+ menu_option(1, "sqlfluff - Download SQLFluff configuration")
50
+ menu_option(2, "pipelines - Download CI/CD pipeline files")
51
+ menu_option(3, "dbt packages - Download dbt package versions")
52
+ menu_option(4, "skills - Cortex Code skills")
53
+ menu_option(0, "back - Return to main menu")
54
+
55
+ def _skills_menu(self) -> None:
56
+ clear()
57
+ display_action_header("Cortex Code Skills")
58
+ menu_option(1, "list - List available skills")
59
+ menu_option(2, "install - Install a specific skill")
60
+ menu_option(3, "install all - Install all available skills")
61
+ menu_option(4, "update - Update installed skills")
62
+ menu_option(0, "back - Return to downloads menu")
63
+ option = get_input_number("Choose an option: ")
64
+ if option == 1:
65
+ self.skills.list_skills()
66
+ elif option == 2:
67
+ available = self.skills.get_available_skills()
68
+ if available:
69
+ names = {s.name: s for s in available}
70
+ selected = select_from_list(list(names.keys()), "skill")
71
+ if selected != "back" and selected in names:
72
+ self.skills.install_skill(names[selected])
73
+ else:
74
+ info_line("No skills available")
75
+ elif option == 3:
76
+ self.skills.install_all()
77
+ elif option == 4:
78
+ self.skills.update_skills()
79
+ complete_action()
@@ -0,0 +1,213 @@
1
+ from datasecops_cli.services.git_service import GitService
2
+ from datasecops_cli.models.project_config import SourceControl
3
+ from datasecops_cli.utilities.display import (
4
+ clear, section_header, display_action_header, menu_option,
5
+ get_input_number, get_input_string, get_input_true_false,
6
+ complete_action, info_line, error_line, warning_line,
7
+ select_from_dict, select_from_list
8
+ )
9
+
10
+
11
+ class GitOperationsMenu:
12
+ def __init__(self, git_service: GitService, source_control: SourceControl,
13
+ deployment_branches: dict[str, str], profile_name: str):
14
+ self.git = git_service
15
+ self.source_control = source_control
16
+ self.deployment_branches = deployment_branches
17
+ self.profile_name = profile_name
18
+
19
+ def show(self) -> None:
20
+ self._menu()
21
+ option = get_input_number("Choose an option: ")
22
+ while option != 0:
23
+ if option == 1:
24
+ self._branch_menu()
25
+ elif option == 2:
26
+ self._commit()
27
+ elif option == 3:
28
+ self._push()
29
+ elif option == 4:
30
+ self._pull()
31
+ elif option == 5:
32
+ self._rebase_menu()
33
+ elif option == 6:
34
+ self._deploy()
35
+ elif option == 7:
36
+ self._squash_merge_test()
37
+ elif option == 8:
38
+ self._cherry_pick_test()
39
+ self._menu()
40
+ option = get_input_number("Choose an option: ")
41
+
42
+ def _menu(self) -> None:
43
+ clear()
44
+ section_header("Git Operations", self.profile_name, self.git.get_current_branch())
45
+ menu_option(1, "branching - Manage branches")
46
+ menu_option(2, "commit - Commit changes")
47
+ menu_option(3, "push - Push to remote")
48
+ menu_option(4, "pull - Pull from remote")
49
+ menu_option(5, "rebase - Rebase with main")
50
+ menu_option(6, "deploy - Deploy to environment")
51
+ menu_option(7, "squash to test - Squash merge into test")
52
+ menu_option(8, "cherry-pick test - Cherry-pick commits from test")
53
+ menu_option(0, "back - Return to main menu")
54
+
55
+ def _branch_menu(self) -> None:
56
+ clear()
57
+ display_action_header("Branch Management")
58
+ menu_option(1, "new - Create new branch")
59
+ menu_option(2, "checkout - Checkout remote branch")
60
+ menu_option(3, "switch - Switch to local branch")
61
+ menu_option(4, "delete - Delete local branch")
62
+ menu_option(5, "prune - Prune remote branches")
63
+ menu_option(6, "reset - Reset to main")
64
+ menu_option(0, "back - Return to git menu")
65
+ option = get_input_number("Choose an option: ")
66
+ if option == 1:
67
+ self._create_branch()
68
+ elif option == 2:
69
+ branches = self.git.get_remote_branches()
70
+ if branches:
71
+ selected = select_from_dict(branches, "branch")
72
+ if selected != "back":
73
+ self.git.checkout_branch(selected)
74
+ else:
75
+ info_line("No remote branches found")
76
+ elif option == 3:
77
+ branches = self.git.get_local_branches()
78
+ selected = select_from_dict(branches, "branch")
79
+ if selected != "back":
80
+ self.git.switch_branch(selected)
81
+ elif option == 4:
82
+ branches = self.git.get_local_branches()
83
+ selected = select_from_dict(branches, "branch")
84
+ if selected != "back":
85
+ self.git.delete_branch(selected)
86
+ elif option == 5:
87
+ self.git.prune_remote_branches()
88
+ elif option == 6:
89
+ if get_input_true_false("This will delete all local branches. Continue?", "n"):
90
+ self.git.reset_to_main()
91
+ complete_action()
92
+
93
+ def _create_branch(self) -> None:
94
+ branch_types = {bt.name: bt.name for bt in self.source_control.branch_types}
95
+ if not branch_types:
96
+ branch_types = {"feature": "feature", "bugfix": "bugfix", "hotfix": "hotfix"}
97
+
98
+ selected_type = select_from_dict(branch_types, "branch type")
99
+ if selected_type == "back":
100
+ return
101
+
102
+ ticket = ""
103
+ if self.source_control.ticket_number_required:
104
+ ticket = get_input_string("Enter ticket number: ")
105
+ if ticket == "0":
106
+ return
107
+ else:
108
+ ticket = get_input_string("Enter ticket number (or press Enter to skip): ", allow_empty=True)
109
+
110
+ name = get_input_string("Enter branch name: ")
111
+ if name == "0":
112
+ return
113
+
114
+ self.git.create_branch(selected_type, ticket, name.replace(" ", "-").lower())
115
+
116
+ def _commit(self) -> None:
117
+ display_action_header("Commit Changes")
118
+ if not self.git.is_dirty():
119
+ info_line("No changes to commit")
120
+ complete_action()
121
+ return
122
+
123
+ info_line(f"{self.git.get_uncommitted_file_count()} change(s) detected")
124
+ for f in self.git.get_changed_files():
125
+ info_line(f" M {f.file}")
126
+ for f in self.git.get_new_files():
127
+ info_line(f" + {f.file}")
128
+
129
+ message = get_input_string("Enter commit message (0 to cancel): ")
130
+ if message == "0":
131
+ return
132
+
133
+ self.git.commit_changes(message)
134
+ self.git.push_branch()
135
+ complete_action()
136
+
137
+ def _push(self) -> None:
138
+ display_action_header("Push")
139
+ self.git.push_branch()
140
+ complete_action()
141
+
142
+ def _pull(self) -> None:
143
+ display_action_header("Pull")
144
+ self.git.pull_branch()
145
+ complete_action()
146
+
147
+ def _rebase_menu(self) -> None:
148
+ clear()
149
+ display_action_header("Rebase Options")
150
+ menu_option(1, "rebase - Standard rebase with main")
151
+ menu_option(2, "squash & rebase - Squash commits then rebase")
152
+ menu_option(3, "continue - Continue after resolving conflicts")
153
+ menu_option(4, "abort - Abort rebase")
154
+ menu_option(0, "back - Return to git menu")
155
+ option = get_input_number("Choose an option: ")
156
+ if option == 1:
157
+ self.git.rebase_with_main()
158
+ elif option == 2:
159
+ self.git.squash_and_rebase()
160
+ elif option == 3:
161
+ self.git.rebase_continue()
162
+ elif option == 4:
163
+ self.git.rebase_abort()
164
+ complete_action()
165
+
166
+ def _deploy(self) -> None:
167
+ display_action_header("Deploy to Environment")
168
+ if not self.deployment_branches:
169
+ error_line("No deployment branches configured")
170
+ complete_action()
171
+ return
172
+
173
+ selected = select_from_dict(self.deployment_branches, "environment")
174
+ if selected == "back":
175
+ return
176
+
177
+ current = self.git.get_current_branch()
178
+ if selected.lower() == "prod" and current.lower() != "main":
179
+ warning_line("Production deployments must come from the main branch")
180
+ if get_input_true_false("Switch to main and deploy?", "n"):
181
+ self.git.checkout_branch("main")
182
+ self.git.pull_branch()
183
+ else:
184
+ return
185
+
186
+ force = selected.lower() != "prod"
187
+ self.git.push_branch_to_destination(selected, force=force)
188
+ complete_action()
189
+
190
+ def _squash_merge_test(self) -> None:
191
+ display_action_header("Squash Merge into Test")
192
+ current = self.git.get_current_branch()
193
+ info_line(f"This will squash merge {current} into the test branch")
194
+ if get_input_true_false("Continue?", "n"):
195
+ self.git.squash_merge_into_test()
196
+ complete_action()
197
+
198
+ def _cherry_pick_test(self) -> None:
199
+ display_action_header("Cherry-pick from Test")
200
+ commits = self.git.get_test_commits()
201
+ if not commits:
202
+ info_line("No commits found on test branch")
203
+ complete_action()
204
+ return
205
+
206
+ info_line("Recent commits on test:")
207
+ for i, c in enumerate(commits):
208
+ info_line(f" [{i+1}] {c['sha'][:8]} - {c['message'][:60]}")
209
+
210
+ choice = get_input_number("Select commit number (0 to cancel): ")
211
+ if 0 < choice <= len(commits):
212
+ self.git.cherry_pick_from_test(commits[choice-1]["sha"])
213
+ complete_action()
@@ -0,0 +1 @@
1
+