datasecops-cli 0.4.7__tar.gz → 0.4.9__tar.gz

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 (56) hide show
  1. {datasecops_cli-0.4.7 → datasecops_cli-0.4.9}/CHANGELOG.md +28 -0
  2. {datasecops_cli-0.4.7 → datasecops_cli-0.4.9}/PKG-INFO +1 -1
  3. datasecops_cli-0.4.9/docs/git-operations.md +205 -0
  4. {datasecops_cli-0.4.7 → datasecops_cli-0.4.9}/pyproject.toml +1 -1
  5. datasecops_cli-0.4.9/src/datasecops_cli/__init__.py +1 -0
  6. {datasecops_cli-0.4.7 → datasecops_cli-0.4.9}/src/datasecops_cli/main.py +33 -0
  7. {datasecops_cli-0.4.7 → datasecops_cli-0.4.9}/src/datasecops_cli/menus/configuration.py +17 -0
  8. {datasecops_cli-0.4.7 → datasecops_cli-0.4.9}/src/datasecops_cli/menus/git_operations.py +111 -40
  9. {datasecops_cli-0.4.7 → datasecops_cli-0.4.9}/src/datasecops_cli/models/project_config.py +1 -0
  10. {datasecops_cli-0.4.7 → datasecops_cli-0.4.9}/src/datasecops_cli/services/dbt_runner.py +63 -5
  11. datasecops_cli-0.4.9/src/datasecops_cli/services/git_service.py +399 -0
  12. datasecops_cli-0.4.7/src/datasecops_cli/__init__.py +0 -1
  13. datasecops_cli-0.4.7/src/datasecops_cli/services/git_service.py +0 -183
  14. {datasecops_cli-0.4.7 → datasecops_cli-0.4.9}/.github/workflows/auto-tag.yml +0 -0
  15. {datasecops_cli-0.4.7 → datasecops_cli-0.4.9}/.github/workflows/publish-cli.yml +0 -0
  16. {datasecops_cli-0.4.7 → datasecops_cli-0.4.9}/.gitignore +0 -0
  17. {datasecops_cli-0.4.7 → datasecops_cli-0.4.9}/DEVELOPMENT.md +0 -0
  18. {datasecops_cli-0.4.7 → datasecops_cli-0.4.9}/LICENSE +0 -0
  19. {datasecops_cli-0.4.7 → datasecops_cli-0.4.9}/README.md +0 -0
  20. {datasecops_cli-0.4.7 → datasecops_cli-0.4.9}/docs/getting-started.md +0 -0
  21. {datasecops_cli-0.4.7 → datasecops_cli-0.4.9}/docs/legacy.md +0 -0
  22. {datasecops_cli-0.4.7 → datasecops_cli-0.4.9}/docs/legacy_plan_of_action.md +0 -0
  23. {datasecops_cli-0.4.7 → datasecops_cli-0.4.9}/docs/mcp-server.md +0 -0
  24. {datasecops_cli-0.4.7 → datasecops_cli-0.4.9}/mcp-servers.json +0 -0
  25. {datasecops_cli-0.4.7 → datasecops_cli-0.4.9}/setup.ps1 +0 -0
  26. {datasecops_cli-0.4.7 → datasecops_cli-0.4.9}/setup.sh +0 -0
  27. {datasecops_cli-0.4.7 → datasecops_cli-0.4.9}/src/datasecops_cli/config.py +0 -0
  28. {datasecops_cli-0.4.7 → datasecops_cli-0.4.9}/src/datasecops_cli/menus/__init__.py +0 -0
  29. {datasecops_cli-0.4.7 → datasecops_cli-0.4.9}/src/datasecops_cli/menus/development.py +0 -0
  30. {datasecops_cli-0.4.7 → datasecops_cli-0.4.9}/src/datasecops_cli/menus/downloads.py +0 -0
  31. {datasecops_cli-0.4.7 → datasecops_cli-0.4.9}/src/datasecops_cli/models/__init__.py +0 -0
  32. {datasecops_cli-0.4.7 → datasecops_cli-0.4.9}/src/datasecops_cli/models/git_helpers.py +0 -0
  33. {datasecops_cli-0.4.7 → datasecops_cli-0.4.9}/src/datasecops_cli/services/__init__.py +0 -0
  34. {datasecops_cli-0.4.7 → datasecops_cli-0.4.9}/src/datasecops_cli/services/bootstrap_service.py +0 -0
  35. {datasecops_cli-0.4.7 → datasecops_cli-0.4.9}/src/datasecops_cli/services/dbt_project_generator.py +0 -0
  36. {datasecops_cli-0.4.7 → datasecops_cli-0.4.9}/src/datasecops_cli/services/directory_scaffolder.py +0 -0
  37. {datasecops_cli-0.4.7 → datasecops_cli-0.4.9}/src/datasecops_cli/services/download_service.py +0 -0
  38. {datasecops_cli-0.4.7 → datasecops_cli-0.4.9}/src/datasecops_cli/services/linting_service.py +0 -0
  39. {datasecops_cli-0.4.7 → datasecops_cli-0.4.9}/src/datasecops_cli/services/skill_service.py +0 -0
  40. {datasecops_cli-0.4.7 → datasecops_cli-0.4.9}/src/datasecops_cli/services/snowflake_service.py +0 -0
  41. {datasecops_cli-0.4.7 → datasecops_cli-0.4.9}/src/datasecops_cli/services/upstream_service.py +0 -0
  42. {datasecops_cli-0.4.7 → datasecops_cli-0.4.9}/src/datasecops_cli/utilities/__init__.py +0 -0
  43. {datasecops_cli-0.4.7 → datasecops_cli-0.4.9}/src/datasecops_cli/utilities/display.py +0 -0
  44. {datasecops_cli-0.4.7 → datasecops_cli-0.4.9}/src/datasecops_cli/utilities/file_utils.py +0 -0
  45. {datasecops_cli-0.4.7 → datasecops_cli-0.4.9}/src/datasecops_cli/utilities/yaml_utils.py +0 -0
  46. {datasecops_cli-0.4.7 → datasecops_cli-0.4.9}/src/datasecops_mcp/__init__.py +0 -0
  47. {datasecops_cli-0.4.7 → datasecops_cli-0.4.9}/src/datasecops_mcp/__main__.py +0 -0
  48. {datasecops_cli-0.4.7 → datasecops_cli-0.4.9}/src/datasecops_mcp/connection.py +0 -0
  49. {datasecops_cli-0.4.7 → datasecops_cli-0.4.9}/src/datasecops_mcp/server.py +0 -0
  50. {datasecops_cli-0.4.7 → datasecops_cli-0.4.9}/tests/__init__.py +0 -0
  51. {datasecops_cli-0.4.7 → datasecops_cli-0.4.9}/tests/test_config.py +0 -0
  52. {datasecops_cli-0.4.7 → datasecops_cli-0.4.9}/tests/test_file_utils.py +0 -0
  53. {datasecops_cli-0.4.7 → datasecops_cli-0.4.9}/tests/test_main.py +0 -0
  54. {datasecops_cli-0.4.7 → datasecops_cli-0.4.9}/tests/test_models.py +0 -0
  55. {datasecops_cli-0.4.7 → datasecops_cli-0.4.9}/tests/test_version.py +0 -0
  56. {datasecops_cli-0.4.7 → datasecops_cli-0.4.9}/tests/test_yaml_utils.py +0 -0
@@ -2,6 +2,34 @@
2
2
 
3
3
  All notable changes to the DataSecOps CLI are documented in this file.
4
4
 
5
+ ## [0.4.9] - 2026-05-21
6
+
7
+ ### Added
8
+
9
+ - **Remove from test** — new git menu option to remove a branch from the test integration branch. Displays squash-merged branches by name and drops the selected commit from test's history via rebase, then force-pushes. The test branch is never deleted.
10
+ - **Branch cleanup** — new option in branch management to delete all local branches (except main, test, and current) and prune stale remote tracking references in one step.
11
+ - **Git operations documentation** — `docs/git-operations.md` documenting the full branch strategy, menu structure, and behaviour of each operation.
12
+ - **Ticket integration plan** — `plans/ticket-integration-plan.md` specifying the design for Azure DevOps, Jira, and GitHub Projects ticket fetching during branch creation.
13
+
14
+ ### Changed
15
+
16
+ - **Squash merge into test rebases onto main first** — prevents drift by rebasing the test branch onto `origin/main` before squash-merging, ensuring test always has main as its base with squash commits layered on top.
17
+ - **Branch creation uses configured `branch_format`** — branch names are now built using the `branch_format` field from the framework's SOURCE_CONTROL config instead of a hardcoded pattern.
18
+ - **Deploy to prod enforced from main only** — production deployments are hardcoded to `force=False` and can only originate from the main branch.
19
+ - **Protected branches** — `main` and `test` cannot be deleted via delete, cleanup, or reset operations.
20
+ - **Stash/restore on branch-switching operations** — squash merge and remove from test now stash uncommitted changes before switching branches and restore them afterward, preventing dirty-tree errors.
21
+ - **Test branch auto-creation** — if neither local nor remote `test` branch exists, it is created from `origin/main` automatically during squash merge or remove operations.
22
+
23
+ ## [0.4.8] - 2026-05-20
24
+
25
+ ### Added
26
+
27
+ - **dbt Fusion update menu option** — new `[7] dbtf update` in the Configuration menu runs `dbtf system update` to upgrade dbt Fusion to the latest version. Checks that `dbtf` is on PATH before attempting the update.
28
+
29
+ ### Fixed
30
+
31
+ - **`dbt docs generate` replaced with `dbt compile --write-catalog` for dbt Fusion** — dbt Fusion does not support `dbt docs generate`. The docs menu option now runs `dbtf compile --write-catalog` when using dbt Fusion, writing `catalog.json` to the target directory. Serving docs automatically downloads `index.html` from dbt-core if missing, launches `python -m http.server 8080` in a new terminal window, and opens the browser at `http://localhost:8080`. The CLI is not blocked — close the server terminal when done.
32
+
5
33
  ## [0.4.7] - 2026-05-19
6
34
 
7
35
  ### Fixed
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: datasecops-cli
3
- Version: 0.4.7
3
+ Version: 0.4.9
4
4
  Summary: DataSecOps Framework CLI for Snowflake Native App
5
5
  License-Expression: MIT
6
6
  License-File: LICENSE
@@ -0,0 +1,205 @@
1
+ # Git Operations
2
+
3
+ The CLI provides a structured git workflow designed around a branching strategy where feature branches are squash-merged into a `test` branch for integration testing, and only `main` is deployed to production.
4
+
5
+ ## Branch Strategy
6
+
7
+ ```
8
+ main ─────────────────────────────────────── prod
9
+
10
+ ├── feature/ticket_name ──┐
11
+ ├── bugfix/ticket_name ──┤── squash merge ──► test
12
+ └── hotfix/ticket_name ──┘
13
+ ```
14
+
15
+ - `main` is the source of truth and the only branch that deploys to production
16
+ - `test` is an integration branch built on top of main (rebased to prevent drift)
17
+ - Feature/bugfix/hotfix branches are created from main and squash-merged into test
18
+ - Branches can be removed from test without affecting main or other branches on test
19
+
20
+ ## Main Menu
21
+
22
+ | Option | Name | Description |
23
+ |--------|------|-------------|
24
+ | 1 | Branching | Manage branches (create, checkout, switch, delete, prune, cleanup, reset) |
25
+ | 2 | Commit | Stage all changes and commit with a message, then push |
26
+ | 3 | Push | Push current branch to remote |
27
+ | 4 | Pull | Pull latest changes from remote for current branch |
28
+ | 5 | Rebase | Rebase current branch with main |
29
+ | 6 | Deploy | Deploy to an environment branch (e.g. test, prod) |
30
+ | 7 | Squash to test | Squash merge current branch into test |
31
+ | 8 | Remove from test | Remove a branch's changes from test |
32
+
33
+ ---
34
+
35
+ ## Operations Detail
36
+
37
+ ### 1. Branching
38
+
39
+ Submenu for branch management operations.
40
+
41
+ | Option | Name | Description |
42
+ |--------|------|-------------|
43
+ | 1 | New | Create a new branch from main |
44
+ | 2 | Checkout | Checkout a remote branch locally |
45
+ | 3 | Switch | Switch to an existing local branch |
46
+ | 4 | Delete | Delete a local branch (and its remote) |
47
+ | 5 | Prune | Remove stale remote tracking references |
48
+ | 6 | Cleanup | Delete all local branches (except main, test, current) and prune remote |
49
+ | 7 | Reset | Hard reset to main and delete all local branches |
50
+
51
+ #### New Branch
52
+
53
+ Creates a branch using the naming convention `{type}/{ticket}_{name}`:
54
+ - Prompts for branch type (feature, bugfix, hotfix — or configured types)
55
+ - Prompts for ticket number (required or optional based on config)
56
+ - Prompts for branch name
57
+ - Creates from `origin/main`, checks out, and pushes with upstream tracking
58
+
59
+ #### Delete Branch
60
+
61
+ - Cannot delete the current branch
62
+ - Cannot delete protected branches (`main`, `test`)
63
+ - Deletes both the local branch and the remote branch
64
+
65
+ #### Cleanup
66
+
67
+ - Deletes all local branches except `main`, `test`, and the current branch
68
+ - Prunes stale remote tracking references
69
+
70
+ #### Reset to Main
71
+
72
+ - Checks out main and hard resets to `origin/main`
73
+ - Deletes all local branches except `main` and `test`
74
+
75
+ ---
76
+
77
+ ### 2. Commit
78
+
79
+ - Detects uncommitted changes (modified and untracked files)
80
+ - Displays a summary of changed files
81
+ - Prompts for a commit message
82
+ - Stages all changes, commits, and pushes to remote
83
+
84
+ ---
85
+
86
+ ### 3. Push
87
+
88
+ Pushes the current branch to its remote counterpart.
89
+
90
+ ---
91
+
92
+ ### 4. Pull
93
+
94
+ Pulls the latest changes from remote for the current branch.
95
+
96
+ ---
97
+
98
+ ### 5. Rebase
99
+
100
+ Submenu for rebase operations.
101
+
102
+ | Option | Name | Description |
103
+ |--------|------|-------------|
104
+ | 1 | Rebase | Standard rebase of current branch onto `origin/main` |
105
+ | 2 | Squash & rebase | Squash all commits into one, then rebase onto main |
106
+ | 3 | Continue | Continue rebase after resolving conflicts |
107
+ | 4 | Abort | Abort an in-progress rebase |
108
+
109
+ #### Standard Rebase
110
+
111
+ Fetches from origin and rebases the current branch onto `origin/main`. If conflicts occur, you can resolve them and use "continue" or "abort".
112
+
113
+ #### Squash & Rebase
114
+
115
+ Soft-resets to the main branch point, creates a single squash commit, then rebases onto `origin/main`. This gives you a clean single commit on top of main.
116
+
117
+ ---
118
+
119
+ ### 6. Deploy
120
+
121
+ Pushes the current branch to a configured deployment branch.
122
+
123
+ **Production rules:**
124
+ - Production deployments can only come from the `main` branch
125
+ - If not on main, the CLI will offer to switch to main first
126
+ - Production pushes are never force-pushed
127
+
128
+ **Non-production environments:**
129
+ - Any branch can be pushed to non-production environments
130
+ - Force-push is used for non-production deployments
131
+
132
+ ---
133
+
134
+ ### 7. Squash to Test
135
+
136
+ Squash merges the current feature branch into the `test` integration branch.
137
+
138
+ **Pre-flight checks (before merge):**
139
+
140
+ 1. **Branch must be up to date with main** — if your branch is behind `origin/main`, the merge is rejected. Rebase with main first.
141
+ 2. **File conflict detection** — checks if any files modified on your branch are also modified by another branch's squash-merge commit currently on test:
142
+ - If the conflicting branch **still exists on remote** → merge is **rejected**. You must resolve with the branch owner or remove that branch from test first.
143
+ - If the conflicting branch **no longer exists** (stale) → the stale commit is **automatically removed** from test before proceeding.
144
+
145
+ **What it does (after pre-flight passes):**
146
+ 1. Stashes any uncommitted changes
147
+ 2. Fetches latest from origin
148
+ 3. Checks out the `test` branch (creates it from `origin/main` if it doesn't exist)
149
+ 4. Pulls latest test
150
+ 5. Rebases test onto `origin/main` to prevent drift from main
151
+ 6. Squash merges the feature branch into test
152
+ 7. Commits with message: `squash merge: {branch_name} into test`
153
+ 8. Force-pushes test (required due to the rebase)
154
+ 9. Checks out the original branch and restores stashed changes
155
+
156
+ **Key behaviours:**
157
+ - The test branch always has `main` as its base — squash commits are layered on top
158
+ - Force-push is used because the rebase rewrites test's history to stay aligned with main
159
+ - The commit message naming convention enables the "remove from test" feature
160
+ - File overlap detection prevents two active branches from silently overwriting each other's changes on test
161
+
162
+ ---
163
+
164
+ ### 8. Remove from Test
165
+
166
+ Removes a previously squash-merged branch from the test branch by dropping its commit from history.
167
+
168
+ **What it does:**
169
+ 1. Fetches latest and lists all squash-merge commits on test (identified by commit message prefix `squash merge:`)
170
+ 2. Displays the branches currently on test by name
171
+ 3. User selects which branch to remove
172
+ 4. Stashes any uncommitted changes
173
+ 5. Checks out test
174
+ 6. Rebases test, dropping the selected commit: `git rebase --onto <commit>^ <commit> test`
175
+ 7. Force-pushes test
176
+ 8. Checks out the original branch and restores stashed changes
177
+
178
+ **Key behaviours:**
179
+ - The commit is completely removed from test's history (not reverted)
180
+ - All other branches on test remain intact
181
+ - The test branch itself is never deleted
182
+ - If the rebase fails (e.g. conflicts), it aborts and returns to the original branch
183
+
184
+ ---
185
+
186
+ ## Protected Branches
187
+
188
+ The following branches are protected and cannot be deleted:
189
+ - `main` — source of truth, deploys to production
190
+ - `test` — integration branch for testing
191
+
192
+ These are protected across all operations: delete, cleanup, and reset.
193
+
194
+ ## Error Handling
195
+
196
+ All operations that switch branches follow a safe pattern:
197
+ 1. Stash uncommitted changes before switching
198
+ 2. Perform the operation
199
+ 3. Switch back to the original branch
200
+ 4. Restore stashed changes
201
+
202
+ If any step fails, the error handler will:
203
+ - Abort any in-progress rebase/revert
204
+ - Switch back to the original branch
205
+ - Restore stashed changes
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "datasecops-cli"
7
- version = "0.4.7"
7
+ version = "0.4.9"
8
8
  description = "DataSecOps Framework CLI for Snowflake Native App"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -0,0 +1 @@
1
+ __version__ = "0.4.8"
@@ -196,6 +196,14 @@ def _run_setup(project_dir: Path):
196
196
  if raw_sc:
197
197
  source_control = SourceControl(**{k: v for k, v in raw_sc.items() if k in SourceControl.model_fields})
198
198
 
199
+ # Get account identifier for MCP URL construction
200
+ account_identifier = ""
201
+ try:
202
+ org_rows = temp_sf.execute_query("SELECT CURRENT_ORGANIZATION_NAME() || '-' || CURRENT_ACCOUNT_NAME() AS acct")
203
+ account_identifier = org_rows[0]["ACCT"].lower() if org_rows else ""
204
+ except Exception as e:
205
+ warning_line(f"Could not determine account identifier (AI Agent MCP will be skipped): {e}")
206
+
199
207
  # --- Resolve profile name ---
200
208
  from datasecops_cli.utilities.yaml_utils import get_profile_name
201
209
  profile_name = get_profile_name(project_dir)
@@ -286,6 +294,11 @@ def _run_setup(project_dir: Path):
286
294
  platform_choice = source_control.source_control_platform
287
295
  info_line(f"Platform: {platform_choice}")
288
296
 
297
+ # Build AI Agent MCP URL once (used in both Cortex Code and mcp.json paths)
298
+ ai_agent_mcp_url = ""
299
+ if project_settings.ai_agents_enabled and account_identifier:
300
+ ai_agent_mcp_url = f"https://{account_identifier}.snowflakecomputing.com/api/v2/databases/{app_database}/schemas/AI/mcp-servers/DATASECOPS_MCP"
301
+
289
302
  info_line("")
290
303
  info_line("Which AI tool are you configuring?")
291
304
  tool_options = ["VS Code (Copilot)", "Cursor", "Claude Code"]
@@ -304,6 +317,18 @@ def _run_setup(project_dir: Path):
304
317
  except FileNotFoundError:
305
318
  warning_line("cortex not found — run manually: cortex mcp add datasecops-framework -- datasecops-mcp")
306
319
 
320
+ # Add AI Agent MCP server if enabled
321
+ if ai_agent_mcp_url:
322
+ try:
323
+ result = subprocess.run(["cortex", "mcp", "add", "datasecops-docs", ai_agent_mcp_url, "--transport", "http"],
324
+ capture_output=True, text=True)
325
+ if result.returncode == 0:
326
+ success_line("Added datasecops-docs MCP server (AI Agent documentation search)")
327
+ else:
328
+ warning_line(f"Failed to add AI Agent MCP server: {result.stderr.strip() or 'unknown error'}")
329
+ except Exception as agent_err:
330
+ warning_line(f"Could not add AI Agent MCP server: {agent_err}")
331
+
307
332
  if platform_choice == "GitHub":
308
333
  github_token = get_input_string("Enter your GitHub PAT (or press Enter to skip): ", allow_empty=True)
309
334
  if github_token:
@@ -343,6 +368,14 @@ def _run_setup(project_dir: Path):
343
368
 
344
369
  servers = {"datasecops-framework": {"command": "datasecops-mcp", "args": []}}
345
370
 
371
+ # Add AI Agent MCP server if enabled
372
+ if ai_agent_mcp_url:
373
+ servers["datasecops-docs"] = {
374
+ "url": ai_agent_mcp_url,
375
+ "headers": {"Authorization": "Bearer <YOUR_PAT>"},
376
+ }
377
+ info_line("Note: Replace <YOUR_PAT> in datasecops-docs headers with your Snowflake PAT")
378
+
346
379
  if platform_choice == "GitHub":
347
380
  servers["github"] = {
348
381
  "command": "npx",
@@ -51,6 +51,8 @@ class ConfigurationMenu:
51
51
  self._cortex_upgrade()
52
52
  elif option == 6:
53
53
  self._toggle_dbt_engine()
54
+ elif option == 7:
55
+ self._dbtf_update()
54
56
  self._menu()
55
57
  option = get_input_number("Choose an option: ")
56
58
 
@@ -64,6 +66,7 @@ class ConfigurationMenu:
64
66
  menu_option(4, "new project - Initialize a new dbt project with framework profiles")
65
67
  menu_option(5, "cortex update - Update Cortex Code to the latest version")
66
68
  menu_option(6, f"dbt engine - Switch dbt engine (current: {engine_label})")
69
+ menu_option(7, "dbtf update - Update dbt Fusion to the latest version")
67
70
  menu_option(0, "back - Return to main menu")
68
71
 
69
72
  def _install_dbt_requirements(self) -> None:
@@ -288,3 +291,17 @@ class ConfigurationMenu:
288
291
  success_line(f"dbt engine switched to {self.datasecops_config.get_engine_label()} ({new_engine})")
289
292
  warning_line("Restart the CLI for the change to take effect.")
290
293
  complete_action()
294
+
295
+ def _dbtf_update(self) -> None:
296
+ """Run dbtf system update to upgrade to the latest version."""
297
+ display_action_header("Update dbt Fusion")
298
+ if not shutil.which("dbtf"):
299
+ error_line("dbt Fusion (dbtf) not found. Install it first.")
300
+ complete_action()
301
+ return
302
+ try:
303
+ info_line("Checking for updates...")
304
+ subprocess.run(["dbtf", "system", "update"])
305
+ except FileNotFoundError:
306
+ error_line("dbt Fusion (dbtf) not found.")
307
+ complete_action()
@@ -15,7 +15,7 @@ class GitOperationsMenu:
15
15
  self.source_control = source_control
16
16
  self.deployment_branches = deployment_branches
17
17
  self.profile_name = profile_name
18
-
18
+
19
19
  def show(self) -> None:
20
20
  self._menu()
21
21
  option = get_input_number("Choose an option: ")
@@ -36,9 +36,11 @@ class GitOperationsMenu:
36
36
  self._squash_merge_test()
37
37
  elif option == 8:
38
38
  self._cherry_pick_test()
39
+ elif option == 9:
40
+ self._deploy_prod()
39
41
  self._menu()
40
42
  option = get_input_number("Choose an option: ")
41
-
43
+
42
44
  def _menu(self) -> None:
43
45
  clear()
44
46
  section_header("Git Operations", self.profile_name, self.git.get_current_branch())
@@ -49,9 +51,10 @@ class GitOperationsMenu:
49
51
  menu_option(5, "rebase - Rebase with main")
50
52
  menu_option(6, "deploy - Deploy to environment")
51
53
  menu_option(7, "squash to test - Squash merge into test")
52
- menu_option(8, "cherry-pick test - Cherry-pick commits from test")
54
+ menu_option(8, "remove from test - Remove a branch from test")
55
+ menu_option(9, "deploy to prod - Deploy main to production")
53
56
  menu_option(0, "back - Return to main menu")
54
-
57
+
55
58
  def _branch_menu(self) -> None:
56
59
  clear()
57
60
  display_action_header("Branch Management")
@@ -60,7 +63,8 @@ class GitOperationsMenu:
60
63
  menu_option(3, "switch - Switch to local branch")
61
64
  menu_option(4, "delete - Delete local branch")
62
65
  menu_option(5, "prune - Prune remote branches")
63
- menu_option(6, "reset - Reset to main")
66
+ menu_option(6, "cleanup - Delete local branches & prune remote")
67
+ menu_option(7, "reset - Reset to main")
64
68
  menu_option(0, "back - Return to git menu")
65
69
  option = get_input_number("Choose an option: ")
66
70
  if option == 1:
@@ -86,19 +90,21 @@ class GitOperationsMenu:
86
90
  elif option == 5:
87
91
  self.git.prune_remote_branches()
88
92
  elif option == 6:
93
+ self.git.cleanup_branches()
94
+ elif option == 7:
89
95
  if get_input_true_false("This will delete all local branches. Continue?", "n"):
90
96
  self.git.reset_to_main()
91
97
  complete_action()
92
-
98
+
93
99
  def _create_branch(self) -> None:
94
100
  branch_types = {bt.name: bt.name for bt in self.source_control.branch_types}
95
101
  if not branch_types:
96
102
  branch_types = {"feature": "feature", "bugfix": "bugfix", "hotfix": "hotfix"}
97
-
103
+
98
104
  selected_type = select_from_dict(branch_types, "branch type")
99
105
  if selected_type == "back":
100
106
  return
101
-
107
+
102
108
  ticket = ""
103
109
  if self.source_control.ticket_number_required:
104
110
  ticket = get_input_string("Enter ticket number: ")
@@ -106,51 +112,59 @@ class GitOperationsMenu:
106
112
  return
107
113
  else:
108
114
  ticket = get_input_string("Enter ticket number (or press Enter to skip): ", allow_empty=True)
109
-
115
+
110
116
  name = get_input_string("Enter branch name: ")
111
117
  if name == "0":
112
118
  return
113
-
114
- self.git.create_branch(selected_type, ticket, name.replace(" ", "-").lower())
115
-
119
+
120
+ branch_name = name.replace(" ", "-").lower()
121
+ fmt = self.source_control.branch_format
122
+ if ticket:
123
+ full_name = fmt.format(branch_type=selected_type, branch_name=f"{ticket}_{branch_name}")
124
+ else:
125
+ full_name = fmt.format(branch_type=selected_type, branch_name=branch_name)
126
+
127
+ self.git.create_branch(full_name)
128
+
116
129
  def _commit(self) -> None:
117
130
  display_action_header("Commit Changes")
118
131
  if not self.git.is_dirty():
119
132
  info_line("No changes to commit")
120
133
  complete_action()
121
134
  return
122
-
135
+
123
136
  info_line(f"{self.git.get_uncommitted_file_count()} change(s) detected")
124
137
  for f in self.git.get_changed_files():
125
138
  info_line(f" M {f.file}")
126
139
  for f in self.git.get_new_files():
127
140
  info_line(f" + {f.file}")
128
-
141
+
129
142
  message = get_input_string("Enter commit message (0 to cancel): ")
130
143
  if message == "0":
131
144
  return
132
-
145
+
133
146
  self.git.commit_changes(message)
134
147
  self.git.push_branch()
135
148
  complete_action()
136
-
149
+
137
150
  def _push(self) -> None:
138
151
  display_action_header("Push")
139
152
  self.git.push_branch()
140
153
  complete_action()
141
-
154
+
142
155
  def _pull(self) -> None:
143
156
  display_action_header("Pull")
144
157
  self.git.pull_branch()
145
158
  complete_action()
146
-
159
+
147
160
  def _rebase_menu(self) -> None:
148
161
  clear()
149
162
  display_action_header("Rebase Options")
150
163
  menu_option(1, "rebase - Standard rebase with main")
151
164
  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")
165
+ menu_option(3, "squash - Squash all commits into one")
166
+ menu_option(4, "continue - Continue after resolving conflicts")
167
+ menu_option(5, "abort - Abort rebase")
154
168
  menu_option(0, "back - Return to git menu")
155
169
  option = get_input_number("Choose an option: ")
156
170
  if option == 1:
@@ -158,56 +172,113 @@ class GitOperationsMenu:
158
172
  elif option == 2:
159
173
  self.git.squash_and_rebase()
160
174
  elif option == 3:
161
- self.git.rebase_continue()
175
+ self.git.squash_commits()
162
176
  elif option == 4:
177
+ self.git.rebase_continue()
178
+ elif option == 5:
163
179
  self.git.rebase_abort()
164
180
  complete_action()
165
-
181
+
166
182
  def _deploy(self) -> None:
167
183
  display_action_header("Deploy to Environment")
168
- if not self.deployment_branches:
184
+
185
+ # Build environment list from framework config, filtering non-deployable environments
186
+ excluded = {"unit-test", "ci", "local-dev", "prod", "test"}
187
+ environments = {}
188
+ if self.source_control.environments:
189
+ for env in self.source_control.environments:
190
+ if env.branch_name and env.branch_name.lower() not in excluded:
191
+ environments[env.branch_name] = env.branch_name
192
+
193
+ # Fall back to deployment_branches if no framework environments configured
194
+ if not environments:
195
+ environments = {
196
+ k: v for k, v in self.deployment_branches.items()
197
+ if k.lower() not in excluded
198
+ }
199
+
200
+ if not environments:
169
201
  error_line("No deployment branches configured")
170
202
  complete_action()
171
203
  return
172
-
173
- selected = select_from_dict(self.deployment_branches, "environment")
204
+
205
+ selected = select_from_dict(environments, "environment")
174
206
  if selected == "back":
175
207
  return
176
-
208
+
209
+ current = self.git.get_current_branch()
210
+ info_line(f"This will squash {current} and force-push to {selected}")
211
+ if get_input_true_false("Continue?", "n"):
212
+ self.git.squash_push_to_destination(selected)
213
+ complete_action()
214
+
215
+ def _deploy_prod(self) -> None:
216
+ display_action_header("Deploy to Production")
177
217
  current = self.git.get_current_branch()
178
- if selected.lower() == "prod" and current.lower() != "main":
218
+ if current.lower() != "main":
179
219
  warning_line("Production deployments must come from the main branch")
180
220
  if get_input_true_false("Switch to main and deploy?", "n"):
181
221
  self.git.checkout_branch("main")
182
222
  self.git.pull_branch()
183
223
  else:
224
+ complete_action()
184
225
  return
185
-
186
- force = selected.lower() != "prod"
187
- self.git.push_branch_to_destination(selected, force=force)
226
+ info_line("This will push main to prod (no force-push)")
227
+ if get_input_true_false("Continue?", "n"):
228
+ self.git.push_branch_to_destination("prod", force=False)
188
229
  complete_action()
189
-
230
+
190
231
  def _squash_merge_test(self) -> None:
191
232
  display_action_header("Squash Merge into Test")
192
233
  current = self.git.get_current_branch()
193
234
  info_line(f"This will squash merge {current} into the test branch")
235
+
236
+ # Check current branch is up to date with main
237
+ if not self.git.is_branch_up_to_date_with_main():
238
+ error_line("Your branch is not up to date with main. Rebase with main first.")
239
+ complete_action()
240
+ return
241
+
242
+ # Check for file overlaps with other branches on test
243
+ conflicts = self.git.get_test_file_conflicts()
244
+ if conflicts:
245
+ # If conflicting branch still exists, reject
246
+ active_conflicts = [c for c in conflicts if c["branch_exists"]]
247
+ if active_conflicts:
248
+ error_line("Cannot merge: files overlap with active branches on test:")
249
+ for c in active_conflicts:
250
+ warning_line(f" {c['branch']}:")
251
+ for f in c["files"]:
252
+ info_line(f" - {f}")
253
+ error_line("Resolve with the branch owner or remove that branch from test first.")
254
+ complete_action()
255
+ return
256
+
257
+ # Conflicting branches no longer exist — remove their stale commits and proceed
258
+ stale_conflicts = [c for c in conflicts if not c["branch_exists"]]
259
+ if stale_conflicts:
260
+ for c in stale_conflicts:
261
+ warning_line(f"Removing stale branch {c['branch']} from test (branch no longer exists)")
262
+ self.git.revert_from_test(c["sha"], c["branch"])
263
+
194
264
  if get_input_true_false("Continue?", "n"):
195
265
  self.git.squash_merge_into_test()
196
266
  complete_action()
197
-
267
+
198
268
  def _cherry_pick_test(self) -> None:
199
- display_action_header("Cherry-pick from Test")
269
+ display_action_header("Remove Branch from Test")
200
270
  commits = self.git.get_test_commits()
201
271
  if not commits:
202
- info_line("No commits found on test branch")
272
+ info_line("No branch merges found on test branch")
203
273
  complete_action()
204
274
  return
205
-
206
- info_line("Recent commits on test:")
275
+
276
+ info_line("Branches currently on test:")
207
277
  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): ")
278
+ info_line(f" [{i+1}] {c['branch']} ({c['sha'][:8]})")
279
+
280
+ choice = get_input_number("Select branch to remove (0 to cancel): ")
211
281
  if 0 < choice <= len(commits):
212
- self.git.cherry_pick_from_test(commits[choice-1]["sha"])
282
+ selected = commits[choice-1]
283
+ self.git.revert_from_test(selected["sha"], selected["branch"])
213
284
  complete_action()
@@ -29,6 +29,7 @@ class ProjectSettings(BaseModel):
29
29
  project_dir: str = "./dbt"
30
30
  profile_dir: str = "~/.dbt"
31
31
  execution_mode: str = "dbt_cli"
32
+ ai_agents_enabled: bool = False
32
33
  targets: list[DbtTarget] = Field(default_factory=lambda: [
33
34
  DbtTarget(target_name="dev", branch_name="dev", target_role="DEVELOPERS", target_warehouse="DEV_WH", is_default=True),
34
35
  DbtTarget(target_name="test", branch_name="test", target_role="DATAOPS_ADMIN", target_warehouse="DATAOPS_WH"),