datasecops-cli 0.4.8__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.
- {datasecops_cli-0.4.8 → datasecops_cli-0.4.9}/CHANGELOG.md +18 -0
- {datasecops_cli-0.4.8 → datasecops_cli-0.4.9}/PKG-INFO +1 -1
- datasecops_cli-0.4.9/docs/git-operations.md +205 -0
- {datasecops_cli-0.4.8 → datasecops_cli-0.4.9}/pyproject.toml +1 -1
- {datasecops_cli-0.4.8 → datasecops_cli-0.4.9}/src/datasecops_cli/menus/git_operations.py +111 -40
- datasecops_cli-0.4.9/src/datasecops_cli/services/git_service.py +399 -0
- datasecops_cli-0.4.8/src/datasecops_cli/services/git_service.py +0 -183
- {datasecops_cli-0.4.8 → datasecops_cli-0.4.9}/.github/workflows/auto-tag.yml +0 -0
- {datasecops_cli-0.4.8 → datasecops_cli-0.4.9}/.github/workflows/publish-cli.yml +0 -0
- {datasecops_cli-0.4.8 → datasecops_cli-0.4.9}/.gitignore +0 -0
- {datasecops_cli-0.4.8 → datasecops_cli-0.4.9}/DEVELOPMENT.md +0 -0
- {datasecops_cli-0.4.8 → datasecops_cli-0.4.9}/LICENSE +0 -0
- {datasecops_cli-0.4.8 → datasecops_cli-0.4.9}/README.md +0 -0
- {datasecops_cli-0.4.8 → datasecops_cli-0.4.9}/docs/getting-started.md +0 -0
- {datasecops_cli-0.4.8 → datasecops_cli-0.4.9}/docs/legacy.md +0 -0
- {datasecops_cli-0.4.8 → datasecops_cli-0.4.9}/docs/legacy_plan_of_action.md +0 -0
- {datasecops_cli-0.4.8 → datasecops_cli-0.4.9}/docs/mcp-server.md +0 -0
- {datasecops_cli-0.4.8 → datasecops_cli-0.4.9}/mcp-servers.json +0 -0
- {datasecops_cli-0.4.8 → datasecops_cli-0.4.9}/setup.ps1 +0 -0
- {datasecops_cli-0.4.8 → datasecops_cli-0.4.9}/setup.sh +0 -0
- {datasecops_cli-0.4.8 → datasecops_cli-0.4.9}/src/datasecops_cli/__init__.py +0 -0
- {datasecops_cli-0.4.8 → datasecops_cli-0.4.9}/src/datasecops_cli/config.py +0 -0
- {datasecops_cli-0.4.8 → datasecops_cli-0.4.9}/src/datasecops_cli/main.py +0 -0
- {datasecops_cli-0.4.8 → datasecops_cli-0.4.9}/src/datasecops_cli/menus/__init__.py +0 -0
- {datasecops_cli-0.4.8 → datasecops_cli-0.4.9}/src/datasecops_cli/menus/configuration.py +0 -0
- {datasecops_cli-0.4.8 → datasecops_cli-0.4.9}/src/datasecops_cli/menus/development.py +0 -0
- {datasecops_cli-0.4.8 → datasecops_cli-0.4.9}/src/datasecops_cli/menus/downloads.py +0 -0
- {datasecops_cli-0.4.8 → datasecops_cli-0.4.9}/src/datasecops_cli/models/__init__.py +0 -0
- {datasecops_cli-0.4.8 → datasecops_cli-0.4.9}/src/datasecops_cli/models/git_helpers.py +0 -0
- {datasecops_cli-0.4.8 → datasecops_cli-0.4.9}/src/datasecops_cli/models/project_config.py +0 -0
- {datasecops_cli-0.4.8 → datasecops_cli-0.4.9}/src/datasecops_cli/services/__init__.py +0 -0
- {datasecops_cli-0.4.8 → datasecops_cli-0.4.9}/src/datasecops_cli/services/bootstrap_service.py +0 -0
- {datasecops_cli-0.4.8 → datasecops_cli-0.4.9}/src/datasecops_cli/services/dbt_project_generator.py +0 -0
- {datasecops_cli-0.4.8 → datasecops_cli-0.4.9}/src/datasecops_cli/services/dbt_runner.py +0 -0
- {datasecops_cli-0.4.8 → datasecops_cli-0.4.9}/src/datasecops_cli/services/directory_scaffolder.py +0 -0
- {datasecops_cli-0.4.8 → datasecops_cli-0.4.9}/src/datasecops_cli/services/download_service.py +0 -0
- {datasecops_cli-0.4.8 → datasecops_cli-0.4.9}/src/datasecops_cli/services/linting_service.py +0 -0
- {datasecops_cli-0.4.8 → datasecops_cli-0.4.9}/src/datasecops_cli/services/skill_service.py +0 -0
- {datasecops_cli-0.4.8 → datasecops_cli-0.4.9}/src/datasecops_cli/services/snowflake_service.py +0 -0
- {datasecops_cli-0.4.8 → datasecops_cli-0.4.9}/src/datasecops_cli/services/upstream_service.py +0 -0
- {datasecops_cli-0.4.8 → datasecops_cli-0.4.9}/src/datasecops_cli/utilities/__init__.py +0 -0
- {datasecops_cli-0.4.8 → datasecops_cli-0.4.9}/src/datasecops_cli/utilities/display.py +0 -0
- {datasecops_cli-0.4.8 → datasecops_cli-0.4.9}/src/datasecops_cli/utilities/file_utils.py +0 -0
- {datasecops_cli-0.4.8 → datasecops_cli-0.4.9}/src/datasecops_cli/utilities/yaml_utils.py +0 -0
- {datasecops_cli-0.4.8 → datasecops_cli-0.4.9}/src/datasecops_mcp/__init__.py +0 -0
- {datasecops_cli-0.4.8 → datasecops_cli-0.4.9}/src/datasecops_mcp/__main__.py +0 -0
- {datasecops_cli-0.4.8 → datasecops_cli-0.4.9}/src/datasecops_mcp/connection.py +0 -0
- {datasecops_cli-0.4.8 → datasecops_cli-0.4.9}/src/datasecops_mcp/server.py +0 -0
- {datasecops_cli-0.4.8 → datasecops_cli-0.4.9}/tests/__init__.py +0 -0
- {datasecops_cli-0.4.8 → datasecops_cli-0.4.9}/tests/test_config.py +0 -0
- {datasecops_cli-0.4.8 → datasecops_cli-0.4.9}/tests/test_file_utils.py +0 -0
- {datasecops_cli-0.4.8 → datasecops_cli-0.4.9}/tests/test_main.py +0 -0
- {datasecops_cli-0.4.8 → datasecops_cli-0.4.9}/tests/test_models.py +0 -0
- {datasecops_cli-0.4.8 → datasecops_cli-0.4.9}/tests/test_version.py +0 -0
- {datasecops_cli-0.4.8 → datasecops_cli-0.4.9}/tests/test_yaml_utils.py +0 -0
|
@@ -2,6 +2,24 @@
|
|
|
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
|
+
|
|
5
23
|
## [0.4.8] - 2026-05-20
|
|
6
24
|
|
|
7
25
|
### Added
|
|
@@ -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
|
|
@@ -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, "
|
|
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, "
|
|
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
|
-
|
|
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, "
|
|
153
|
-
menu_option(4, "
|
|
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.
|
|
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
|
-
|
|
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(
|
|
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
|
|
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
|
-
|
|
187
|
-
|
|
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("
|
|
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
|
|
272
|
+
info_line("No branch merges found on test branch")
|
|
203
273
|
complete_action()
|
|
204
274
|
return
|
|
205
|
-
|
|
206
|
-
info_line("
|
|
275
|
+
|
|
276
|
+
info_line("Branches currently on test:")
|
|
207
277
|
for i, c in enumerate(commits):
|
|
208
|
-
info_line(f" [{i+1}] {c['
|
|
209
|
-
|
|
210
|
-
choice = get_input_number("Select
|
|
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
|
-
|
|
282
|
+
selected = commits[choice-1]
|
|
283
|
+
self.git.revert_from_test(selected["sha"], selected["branch"])
|
|
213
284
|
complete_action()
|
|
@@ -0,0 +1,399 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
from typing import Optional
|
|
3
|
+
from git import Repo, GitCommandError
|
|
4
|
+
|
|
5
|
+
from datasecops_cli.models.git_helpers import GitCommitHelper
|
|
6
|
+
from datasecops_cli.utilities.display import info_line, error_line, success_line, warning_line
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class GitService:
|
|
10
|
+
"""Git operations via GitPython."""
|
|
11
|
+
|
|
12
|
+
def __init__(self, repo_path: Path):
|
|
13
|
+
self.repo = Repo(repo_path)
|
|
14
|
+
|
|
15
|
+
def get_current_branch(self) -> str:
|
|
16
|
+
try:
|
|
17
|
+
return self.repo.active_branch.name
|
|
18
|
+
except TypeError:
|
|
19
|
+
return "DETACHED HEAD"
|
|
20
|
+
|
|
21
|
+
def is_dirty(self) -> bool:
|
|
22
|
+
return self.repo.is_dirty(untracked_files=True)
|
|
23
|
+
|
|
24
|
+
def get_uncommitted_file_count(self) -> int:
|
|
25
|
+
return len(self.repo.index.diff(None)) + len(self.repo.untracked_files)
|
|
26
|
+
|
|
27
|
+
def get_changed_files(self) -> list[GitCommitHelper]:
|
|
28
|
+
return [GitCommitHelper(file=d.a_path) for d in self.repo.index.diff(None)]
|
|
29
|
+
|
|
30
|
+
def get_new_files(self) -> list[GitCommitHelper]:
|
|
31
|
+
return [GitCommitHelper(file=f) for f in self.repo.untracked_files]
|
|
32
|
+
|
|
33
|
+
def get_local_branches(self) -> dict[str, str]:
|
|
34
|
+
return {b.name: b.name for b in self.repo.branches}
|
|
35
|
+
|
|
36
|
+
def get_remote_branches(self) -> dict[str, str]:
|
|
37
|
+
self.repo.remotes.origin.fetch()
|
|
38
|
+
return {
|
|
39
|
+
ref.remote_head: ref.remote_head
|
|
40
|
+
for ref in self.repo.remotes.origin.refs
|
|
41
|
+
if ref.remote_head != "HEAD"
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
def create_branch(self, branch_name: str) -> None:
|
|
45
|
+
info_line(f"Creating branch: {branch_name}")
|
|
46
|
+
self.repo.remotes.origin.fetch()
|
|
47
|
+
new_branch = self.repo.create_head(branch_name, "origin/main")
|
|
48
|
+
new_branch.checkout()
|
|
49
|
+
self.repo.remotes.origin.push(branch_name, set_upstream=True)
|
|
50
|
+
success_line(f"Branch {branch_name} created and checked out")
|
|
51
|
+
|
|
52
|
+
def checkout_branch(self, remote_name: str) -> None:
|
|
53
|
+
info_line(f"Checking out: {remote_name}")
|
|
54
|
+
self.repo.remotes.origin.fetch()
|
|
55
|
+
try:
|
|
56
|
+
self.repo.git.checkout(remote_name)
|
|
57
|
+
except GitCommandError:
|
|
58
|
+
self.repo.git.checkout("-b", remote_name, f"origin/{remote_name}")
|
|
59
|
+
success_line(f"Switched to {remote_name}")
|
|
60
|
+
|
|
61
|
+
def switch_branch(self, name: str) -> None:
|
|
62
|
+
self.repo.branches[name].checkout()
|
|
63
|
+
success_line(f"Switched to {name}")
|
|
64
|
+
|
|
65
|
+
def delete_branch(self, name: str) -> None:
|
|
66
|
+
if name == self.get_current_branch():
|
|
67
|
+
error_line("Cannot delete the current branch")
|
|
68
|
+
return
|
|
69
|
+
if name in ("main", "test"):
|
|
70
|
+
error_line(f"Cannot delete protected branch '{name}'")
|
|
71
|
+
return
|
|
72
|
+
self.repo.delete_head(name, force=True)
|
|
73
|
+
try:
|
|
74
|
+
self.repo.remotes.origin.push(refspec=f":{name}")
|
|
75
|
+
except GitCommandError:
|
|
76
|
+
pass
|
|
77
|
+
success_line(f"Deleted branch {name}")
|
|
78
|
+
|
|
79
|
+
def commit_changes(self, message: str) -> None:
|
|
80
|
+
self.repo.git.add(A=True)
|
|
81
|
+
self.repo.index.commit(message)
|
|
82
|
+
success_line(f"Committed: {message}")
|
|
83
|
+
|
|
84
|
+
def push_branch(self) -> None:
|
|
85
|
+
branch = self.get_current_branch()
|
|
86
|
+
info_line(f"Pushing {branch}...")
|
|
87
|
+
self.repo.remotes.origin.push(branch)
|
|
88
|
+
success_line(f"Pushed {branch}")
|
|
89
|
+
|
|
90
|
+
def pull_branch(self) -> None:
|
|
91
|
+
branch = self.get_current_branch()
|
|
92
|
+
info_line(f"Pulling {branch}...")
|
|
93
|
+
self.repo.remotes.origin.pull(branch)
|
|
94
|
+
success_line(f"Pulled {branch}")
|
|
95
|
+
|
|
96
|
+
def push_branch_to_destination(self, destination: str, force: bool = False) -> None:
|
|
97
|
+
current = self.get_current_branch()
|
|
98
|
+
info_line(f"Pushing {current} to {destination}...")
|
|
99
|
+
refspec = f"{current}:{destination}"
|
|
100
|
+
self.repo.remotes.origin.push(refspec=refspec, force=force)
|
|
101
|
+
success_line(f"Pushed to {destination}")
|
|
102
|
+
|
|
103
|
+
def squash_push_to_destination(self, destination: str) -> None:
|
|
104
|
+
"""Squash all commits on the current branch into one and force-push to destination."""
|
|
105
|
+
current = self.get_current_branch()
|
|
106
|
+
stashed = False
|
|
107
|
+
try:
|
|
108
|
+
if self.repo.is_dirty(untracked_files=True):
|
|
109
|
+
self.repo.git.stash("push", "-u")
|
|
110
|
+
stashed = True
|
|
111
|
+
self.repo.remotes.origin.fetch()
|
|
112
|
+
main_commit = self.repo.commit("origin/main")
|
|
113
|
+
# Create a squashed commit on a temporary detached state
|
|
114
|
+
self.repo.git.reset("--soft", main_commit.hexsha)
|
|
115
|
+
self.repo.index.commit(f"deploy: {current} to {destination}")
|
|
116
|
+
# Force-push to destination
|
|
117
|
+
refspec = f"HEAD:{destination}"
|
|
118
|
+
self.repo.remotes.origin.push(refspec=refspec, force=True)
|
|
119
|
+
# Restore the branch to its original state
|
|
120
|
+
self.repo.git.reset("--soft", f"origin/{current}")
|
|
121
|
+
if stashed:
|
|
122
|
+
self.repo.git.stash("pop")
|
|
123
|
+
success_line(f"Squash deployed {current} to {destination}")
|
|
124
|
+
except GitCommandError as e:
|
|
125
|
+
error_line(f"Squash deploy failed: {e}")
|
|
126
|
+
try:
|
|
127
|
+
self.repo.git.reset("--soft", f"origin/{current}")
|
|
128
|
+
except Exception:
|
|
129
|
+
pass
|
|
130
|
+
if stashed:
|
|
131
|
+
try:
|
|
132
|
+
self.repo.git.stash("pop")
|
|
133
|
+
except Exception:
|
|
134
|
+
pass
|
|
135
|
+
|
|
136
|
+
def rebase_with_main(self) -> bool:
|
|
137
|
+
try:
|
|
138
|
+
self.repo.remotes.origin.fetch()
|
|
139
|
+
self.repo.git.rebase("origin/main")
|
|
140
|
+
success_line("Rebase with main completed")
|
|
141
|
+
return True
|
|
142
|
+
except GitCommandError as e:
|
|
143
|
+
error_line(f"Rebase conflict: {e}")
|
|
144
|
+
warning_line("Resolve conflicts then use 'rebase continue' or 'rebase abort'")
|
|
145
|
+
return False
|
|
146
|
+
|
|
147
|
+
def rebase_continue(self) -> None:
|
|
148
|
+
self.repo.git.rebase("--continue")
|
|
149
|
+
success_line("Rebase continued")
|
|
150
|
+
|
|
151
|
+
def rebase_abort(self) -> None:
|
|
152
|
+
self.repo.git.rebase("--abort")
|
|
153
|
+
success_line("Rebase aborted")
|
|
154
|
+
|
|
155
|
+
def squash_and_rebase(self) -> bool:
|
|
156
|
+
try:
|
|
157
|
+
self.repo.remotes.origin.fetch()
|
|
158
|
+
main_commit = self.repo.commit("origin/main")
|
|
159
|
+
current = self.get_current_branch()
|
|
160
|
+
self.repo.git.reset("--soft", main_commit.hexsha)
|
|
161
|
+
self.repo.index.commit(f"squash: {current}")
|
|
162
|
+
self.repo.git.rebase("origin/main")
|
|
163
|
+
success_line("Squash and rebase completed")
|
|
164
|
+
return True
|
|
165
|
+
except GitCommandError as e:
|
|
166
|
+
error_line(f"Squash rebase failed: {e}")
|
|
167
|
+
return False
|
|
168
|
+
|
|
169
|
+
def squash_commits(self) -> bool:
|
|
170
|
+
"""Squash all commits on the current branch into a single commit and force-push."""
|
|
171
|
+
stashed = False
|
|
172
|
+
try:
|
|
173
|
+
if self.repo.is_dirty(untracked_files=True):
|
|
174
|
+
self.repo.git.stash("push", "-u")
|
|
175
|
+
stashed = True
|
|
176
|
+
self.repo.remotes.origin.fetch()
|
|
177
|
+
main_commit = self.repo.commit("origin/main")
|
|
178
|
+
current = self.get_current_branch()
|
|
179
|
+
# Collect all commit messages from the branch
|
|
180
|
+
commits = list(self.repo.iter_commits(f"origin/main..HEAD"))
|
|
181
|
+
messages = [c.message.strip() for c in reversed(commits)]
|
|
182
|
+
squash_message = f"squash: {current}\n\n" + "\n".join(f"- {m}" for m in messages)
|
|
183
|
+
self.repo.git.reset("--soft", main_commit.hexsha)
|
|
184
|
+
self.repo.index.commit(squash_message)
|
|
185
|
+
self.repo.remotes.origin.push(current, force=True)
|
|
186
|
+
if stashed:
|
|
187
|
+
self.repo.git.stash("pop")
|
|
188
|
+
success_line(f"Squashed {len(commits)} commits on {current} and force-pushed")
|
|
189
|
+
return True
|
|
190
|
+
except GitCommandError as e:
|
|
191
|
+
error_line(f"Squash failed: {e}")
|
|
192
|
+
if stashed:
|
|
193
|
+
try:
|
|
194
|
+
self.repo.git.stash("pop")
|
|
195
|
+
except Exception:
|
|
196
|
+
pass
|
|
197
|
+
return False
|
|
198
|
+
|
|
199
|
+
def _checkout_test(self) -> None:
|
|
200
|
+
"""Checkout the test branch, creating from origin/test or origin/main if needed."""
|
|
201
|
+
try:
|
|
202
|
+
self.repo.git.checkout("test")
|
|
203
|
+
except GitCommandError:
|
|
204
|
+
try:
|
|
205
|
+
self.repo.git.checkout("-b", "test", "origin/test")
|
|
206
|
+
except GitCommandError:
|
|
207
|
+
self.repo.git.checkout("-b", "test", "origin/main")
|
|
208
|
+
|
|
209
|
+
def is_branch_up_to_date_with_main(self) -> bool:
|
|
210
|
+
"""Check if the current branch contains all commits from origin/main."""
|
|
211
|
+
try:
|
|
212
|
+
self.repo.remotes.origin.fetch()
|
|
213
|
+
# If merge-base of HEAD and origin/main equals origin/main, we're up to date
|
|
214
|
+
merge_base = self.repo.git.merge_base("HEAD", "origin/main")
|
|
215
|
+
main_sha = self.repo.commit("origin/main").hexsha
|
|
216
|
+
return merge_base.strip() == main_sha
|
|
217
|
+
except Exception:
|
|
218
|
+
return False
|
|
219
|
+
|
|
220
|
+
def squash_merge_into_test(self) -> bool:
|
|
221
|
+
current = self.get_current_branch()
|
|
222
|
+
stashed = False
|
|
223
|
+
try:
|
|
224
|
+
if self.repo.is_dirty(untracked_files=True):
|
|
225
|
+
self.repo.git.stash("push", "-u")
|
|
226
|
+
stashed = True
|
|
227
|
+
self.repo.remotes.origin.fetch()
|
|
228
|
+
self._checkout_test()
|
|
229
|
+
try:
|
|
230
|
+
self.repo.remotes.origin.pull("test")
|
|
231
|
+
except GitCommandError:
|
|
232
|
+
pass
|
|
233
|
+
# Rebase test onto main to prevent drift
|
|
234
|
+
self.repo.git.rebase("origin/main")
|
|
235
|
+
self.repo.git.merge("--squash", current)
|
|
236
|
+
self.repo.index.commit(f"squash merge: {current} into test")
|
|
237
|
+
self.repo.remotes.origin.push("test", set_upstream=True, force=True)
|
|
238
|
+
self.repo.git.checkout(current)
|
|
239
|
+
if stashed:
|
|
240
|
+
self.repo.git.stash("pop")
|
|
241
|
+
success_line(f"Squash merged {current} into test")
|
|
242
|
+
return True
|
|
243
|
+
except GitCommandError as e:
|
|
244
|
+
error_line(f"Squash merge failed: {e}")
|
|
245
|
+
try:
|
|
246
|
+
self.repo.git.rebase("--abort")
|
|
247
|
+
except Exception:
|
|
248
|
+
pass
|
|
249
|
+
try:
|
|
250
|
+
self.repo.git.checkout(current)
|
|
251
|
+
except Exception:
|
|
252
|
+
pass
|
|
253
|
+
if stashed:
|
|
254
|
+
try:
|
|
255
|
+
self.repo.git.stash("pop")
|
|
256
|
+
except Exception:
|
|
257
|
+
pass
|
|
258
|
+
return False
|
|
259
|
+
|
|
260
|
+
def revert_from_test(self, commit_sha: str, branch_name: str) -> bool:
|
|
261
|
+
current = self.get_current_branch()
|
|
262
|
+
stashed = False
|
|
263
|
+
short_sha = commit_sha[:8]
|
|
264
|
+
try:
|
|
265
|
+
if self.repo.is_dirty(untracked_files=True):
|
|
266
|
+
self.repo.git.stash("push", "-u")
|
|
267
|
+
stashed = True
|
|
268
|
+
self.repo.remotes.origin.fetch()
|
|
269
|
+
self._checkout_test()
|
|
270
|
+
try:
|
|
271
|
+
self.repo.remotes.origin.pull("test")
|
|
272
|
+
except GitCommandError:
|
|
273
|
+
pass
|
|
274
|
+
# Verify commit has exactly one parent (not a merge commit)
|
|
275
|
+
target = self.repo.commit(commit_sha)
|
|
276
|
+
if len(target.parents) != 1:
|
|
277
|
+
error_line(f"Cannot remove {branch_name} ({short_sha}): commit has multiple parents")
|
|
278
|
+
self.repo.git.checkout(current)
|
|
279
|
+
if stashed:
|
|
280
|
+
self.repo.git.stash("pop")
|
|
281
|
+
return False
|
|
282
|
+
self.repo.git.rebase("--onto", f"{commit_sha}^", commit_sha, "test")
|
|
283
|
+
self.repo.remotes.origin.push("test", force=True)
|
|
284
|
+
self.repo.git.checkout(current)
|
|
285
|
+
if stashed:
|
|
286
|
+
self.repo.git.stash("pop")
|
|
287
|
+
success_line(f"Removed {branch_name} from test")
|
|
288
|
+
return True
|
|
289
|
+
except GitCommandError as e:
|
|
290
|
+
error_line(f"Remove failed for {branch_name} ({short_sha}): {e}")
|
|
291
|
+
try:
|
|
292
|
+
self.repo.git.rebase("--abort")
|
|
293
|
+
except Exception:
|
|
294
|
+
pass
|
|
295
|
+
try:
|
|
296
|
+
self.repo.git.checkout(current)
|
|
297
|
+
except Exception:
|
|
298
|
+
pass
|
|
299
|
+
if stashed:
|
|
300
|
+
try:
|
|
301
|
+
self.repo.git.stash("pop")
|
|
302
|
+
except Exception:
|
|
303
|
+
pass
|
|
304
|
+
return False
|
|
305
|
+
|
|
306
|
+
def get_test_file_conflicts(self) -> list[dict]:
|
|
307
|
+
"""Check if files on current branch overlap with existing squash-merge commits on test.
|
|
308
|
+
|
|
309
|
+
Returns list of conflicts with branch name, files, sha, and whether the branch still exists.
|
|
310
|
+
"""
|
|
311
|
+
try:
|
|
312
|
+
self.repo.remotes.origin.fetch()
|
|
313
|
+
current = self.get_current_branch()
|
|
314
|
+
# Files changed on current branch vs main
|
|
315
|
+
branch_files = set(
|
|
316
|
+
self.repo.git.diff("--name-only", "origin/main...HEAD").splitlines()
|
|
317
|
+
)
|
|
318
|
+
if not branch_files:
|
|
319
|
+
return []
|
|
320
|
+
|
|
321
|
+
# Get remote branches for existence check
|
|
322
|
+
remote_branches = {
|
|
323
|
+
ref.remote_head for ref in self.repo.remotes.origin.refs
|
|
324
|
+
if ref.remote_head != "HEAD"
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
# Check each squash-merge commit on test for overlapping files
|
|
328
|
+
conflicts = []
|
|
329
|
+
for c in self.repo.iter_commits("origin/test", max_count=100):
|
|
330
|
+
msg = c.message.strip()
|
|
331
|
+
if msg.startswith("squash merge:"):
|
|
332
|
+
other_branch = msg.replace("squash merge: ", "", 1).replace(" into test", "")
|
|
333
|
+
if other_branch == current:
|
|
334
|
+
continue
|
|
335
|
+
# Files touched by this commit
|
|
336
|
+
commit_files = set(
|
|
337
|
+
self.repo.git.diff_tree("--no-commit-id", "--name-only", "-r", c.hexsha).splitlines()
|
|
338
|
+
)
|
|
339
|
+
overlap = branch_files & commit_files
|
|
340
|
+
if overlap:
|
|
341
|
+
conflicts.append({
|
|
342
|
+
"branch": other_branch,
|
|
343
|
+
"sha": c.hexsha,
|
|
344
|
+
"files": sorted(overlap),
|
|
345
|
+
"branch_exists": other_branch in remote_branches,
|
|
346
|
+
})
|
|
347
|
+
return conflicts
|
|
348
|
+
except Exception:
|
|
349
|
+
return []
|
|
350
|
+
|
|
351
|
+
def get_test_commits(self, limit: int = 20) -> list[dict]:
|
|
352
|
+
try:
|
|
353
|
+
self.repo.remotes.origin.fetch()
|
|
354
|
+
commits = []
|
|
355
|
+
for c in self.repo.iter_commits("origin/test", max_count=100):
|
|
356
|
+
msg = c.message.strip()
|
|
357
|
+
if msg.startswith("squash merge:"):
|
|
358
|
+
branch_name = msg.replace("squash merge: ", "", 1).replace(" into test", "")
|
|
359
|
+
commits.append({
|
|
360
|
+
"sha": c.hexsha,
|
|
361
|
+
"message": msg,
|
|
362
|
+
"branch": branch_name,
|
|
363
|
+
"author": str(c.author),
|
|
364
|
+
"date": str(c.committed_datetime),
|
|
365
|
+
})
|
|
366
|
+
if len(commits) >= limit:
|
|
367
|
+
break
|
|
368
|
+
return commits
|
|
369
|
+
except Exception:
|
|
370
|
+
return []
|
|
371
|
+
|
|
372
|
+
def prune_remote_branches(self) -> None:
|
|
373
|
+
self.repo.remotes.origin.fetch(prune=True)
|
|
374
|
+
success_line("Pruned remote branches")
|
|
375
|
+
|
|
376
|
+
def cleanup_branches(self) -> None:
|
|
377
|
+
current = self.get_current_branch()
|
|
378
|
+
protected = {"main", "test", current}
|
|
379
|
+
deleted = []
|
|
380
|
+
for branch in list(self.repo.branches):
|
|
381
|
+
if branch.name not in protected:
|
|
382
|
+
self.repo.delete_head(branch, force=True)
|
|
383
|
+
deleted.append(branch.name)
|
|
384
|
+
self.repo.remotes.origin.fetch(prune=True)
|
|
385
|
+
if deleted:
|
|
386
|
+
success_line(f"Deleted local branches: {', '.join(deleted)}")
|
|
387
|
+
else:
|
|
388
|
+
info_line("No local branches to delete")
|
|
389
|
+
success_line("Pruned remote tracking branches")
|
|
390
|
+
|
|
391
|
+
def reset_to_main(self) -> None:
|
|
392
|
+
self.repo.remotes.origin.fetch()
|
|
393
|
+
self.repo.git.checkout("main")
|
|
394
|
+
self.repo.git.reset("--hard", "origin/main")
|
|
395
|
+
# Delete all local branches except main and test
|
|
396
|
+
for branch in self.repo.branches:
|
|
397
|
+
if branch.name not in ("main", "test"):
|
|
398
|
+
self.repo.delete_head(branch, force=True)
|
|
399
|
+
success_line("Reset to main completed")
|
|
@@ -1,183 +0,0 @@
|
|
|
1
|
-
from pathlib import Path
|
|
2
|
-
from typing import Optional
|
|
3
|
-
from git import Repo, GitCommandError
|
|
4
|
-
|
|
5
|
-
from datasecops_cli.models.git_helpers import GitCommitHelper
|
|
6
|
-
from datasecops_cli.utilities.display import info_line, error_line, success_line, warning_line
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
class GitService:
|
|
10
|
-
"""Git operations via GitPython."""
|
|
11
|
-
|
|
12
|
-
def __init__(self, repo_path: Path):
|
|
13
|
-
self.repo = Repo(repo_path)
|
|
14
|
-
|
|
15
|
-
def get_current_branch(self) -> str:
|
|
16
|
-
try:
|
|
17
|
-
return self.repo.active_branch.name
|
|
18
|
-
except TypeError:
|
|
19
|
-
return "DETACHED HEAD"
|
|
20
|
-
|
|
21
|
-
def is_dirty(self) -> bool:
|
|
22
|
-
return self.repo.is_dirty(untracked_files=True)
|
|
23
|
-
|
|
24
|
-
def get_uncommitted_file_count(self) -> int:
|
|
25
|
-
return len(self.repo.index.diff(None)) + len(self.repo.untracked_files)
|
|
26
|
-
|
|
27
|
-
def get_changed_files(self) -> list[GitCommitHelper]:
|
|
28
|
-
return [GitCommitHelper(file=d.a_path) for d in self.repo.index.diff(None)]
|
|
29
|
-
|
|
30
|
-
def get_new_files(self) -> list[GitCommitHelper]:
|
|
31
|
-
return [GitCommitHelper(file=f) for f in self.repo.untracked_files]
|
|
32
|
-
|
|
33
|
-
def get_local_branches(self) -> dict[str, str]:
|
|
34
|
-
return {b.name: b.name for b in self.repo.branches}
|
|
35
|
-
|
|
36
|
-
def get_remote_branches(self) -> dict[str, str]:
|
|
37
|
-
self.repo.remotes.origin.fetch()
|
|
38
|
-
return {
|
|
39
|
-
ref.remote_head: ref.remote_head
|
|
40
|
-
for ref in self.repo.remotes.origin.refs
|
|
41
|
-
if ref.remote_head != "HEAD"
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
def create_branch(self, branch_type: str, ticket: str, name: str) -> None:
|
|
45
|
-
branch_name = f"{branch_type}/{ticket}_{name}" if ticket else f"{branch_type}/{name}"
|
|
46
|
-
info_line(f"Creating branch: {branch_name}")
|
|
47
|
-
self.repo.remotes.origin.fetch()
|
|
48
|
-
new_branch = self.repo.create_head(branch_name, "origin/main")
|
|
49
|
-
new_branch.checkout()
|
|
50
|
-
self.repo.remotes.origin.push(branch_name, set_upstream=True)
|
|
51
|
-
success_line(f"Branch {branch_name} created and checked out")
|
|
52
|
-
|
|
53
|
-
def checkout_branch(self, remote_name: str) -> None:
|
|
54
|
-
info_line(f"Checking out: {remote_name}")
|
|
55
|
-
self.repo.remotes.origin.fetch()
|
|
56
|
-
try:
|
|
57
|
-
self.repo.git.checkout(remote_name)
|
|
58
|
-
except GitCommandError:
|
|
59
|
-
self.repo.git.checkout("-b", remote_name, f"origin/{remote_name}")
|
|
60
|
-
success_line(f"Switched to {remote_name}")
|
|
61
|
-
|
|
62
|
-
def switch_branch(self, name: str) -> None:
|
|
63
|
-
self.repo.branches[name].checkout()
|
|
64
|
-
success_line(f"Switched to {name}")
|
|
65
|
-
|
|
66
|
-
def delete_branch(self, name: str) -> None:
|
|
67
|
-
if name == self.get_current_branch():
|
|
68
|
-
error_line("Cannot delete the current branch")
|
|
69
|
-
return
|
|
70
|
-
self.repo.delete_head(name, force=True)
|
|
71
|
-
try:
|
|
72
|
-
self.repo.remotes.origin.push(refspec=f":{name}")
|
|
73
|
-
except GitCommandError:
|
|
74
|
-
pass
|
|
75
|
-
success_line(f"Deleted branch {name}")
|
|
76
|
-
|
|
77
|
-
def commit_changes(self, message: str) -> None:
|
|
78
|
-
self.repo.git.add(A=True)
|
|
79
|
-
self.repo.index.commit(message)
|
|
80
|
-
success_line(f"Committed: {message}")
|
|
81
|
-
|
|
82
|
-
def push_branch(self) -> None:
|
|
83
|
-
branch = self.get_current_branch()
|
|
84
|
-
info_line(f"Pushing {branch}...")
|
|
85
|
-
self.repo.remotes.origin.push(branch)
|
|
86
|
-
success_line(f"Pushed {branch}")
|
|
87
|
-
|
|
88
|
-
def pull_branch(self) -> None:
|
|
89
|
-
branch = self.get_current_branch()
|
|
90
|
-
info_line(f"Pulling {branch}...")
|
|
91
|
-
self.repo.remotes.origin.pull(branch)
|
|
92
|
-
success_line(f"Pulled {branch}")
|
|
93
|
-
|
|
94
|
-
def push_branch_to_destination(self, destination: str, force: bool = False) -> None:
|
|
95
|
-
current = self.get_current_branch()
|
|
96
|
-
info_line(f"Pushing {current} to {destination}...")
|
|
97
|
-
refspec = f"{current}:{destination}"
|
|
98
|
-
self.repo.remotes.origin.push(refspec=refspec, force=force)
|
|
99
|
-
success_line(f"Pushed to {destination}")
|
|
100
|
-
|
|
101
|
-
def rebase_with_main(self) -> bool:
|
|
102
|
-
try:
|
|
103
|
-
self.repo.remotes.origin.fetch()
|
|
104
|
-
self.repo.git.rebase("origin/main")
|
|
105
|
-
success_line("Rebase with main completed")
|
|
106
|
-
return True
|
|
107
|
-
except GitCommandError as e:
|
|
108
|
-
error_line(f"Rebase conflict: {e}")
|
|
109
|
-
warning_line("Resolve conflicts then use 'rebase continue' or 'rebase abort'")
|
|
110
|
-
return False
|
|
111
|
-
|
|
112
|
-
def rebase_continue(self) -> None:
|
|
113
|
-
self.repo.git.rebase("--continue")
|
|
114
|
-
success_line("Rebase continued")
|
|
115
|
-
|
|
116
|
-
def rebase_abort(self) -> None:
|
|
117
|
-
self.repo.git.rebase("--abort")
|
|
118
|
-
success_line("Rebase aborted")
|
|
119
|
-
|
|
120
|
-
def squash_and_rebase(self) -> bool:
|
|
121
|
-
try:
|
|
122
|
-
self.repo.remotes.origin.fetch()
|
|
123
|
-
main_commit = self.repo.commit("origin/main")
|
|
124
|
-
current = self.get_current_branch()
|
|
125
|
-
self.repo.git.reset("--soft", main_commit.hexsha)
|
|
126
|
-
self.repo.index.commit(f"squash: {current}")
|
|
127
|
-
self.repo.git.rebase("origin/main")
|
|
128
|
-
success_line("Squash and rebase completed")
|
|
129
|
-
return True
|
|
130
|
-
except GitCommandError as e:
|
|
131
|
-
error_line(f"Squash rebase failed: {e}")
|
|
132
|
-
return False
|
|
133
|
-
|
|
134
|
-
def squash_merge_into_test(self) -> bool:
|
|
135
|
-
current = self.get_current_branch()
|
|
136
|
-
try:
|
|
137
|
-
self.repo.remotes.origin.fetch()
|
|
138
|
-
self.repo.git.checkout("test")
|
|
139
|
-
self.repo.remotes.origin.pull("test")
|
|
140
|
-
self.repo.git.merge("--squash", current)
|
|
141
|
-
self.repo.index.commit(f"squash merge: {current} into test")
|
|
142
|
-
self.repo.remotes.origin.push("test")
|
|
143
|
-
self.repo.git.checkout(current)
|
|
144
|
-
success_line(f"Squash merged {current} into test")
|
|
145
|
-
return True
|
|
146
|
-
except GitCommandError as e:
|
|
147
|
-
error_line(f"Squash merge failed: {e}")
|
|
148
|
-
try:
|
|
149
|
-
self.repo.git.checkout(current)
|
|
150
|
-
except Exception:
|
|
151
|
-
pass
|
|
152
|
-
return False
|
|
153
|
-
|
|
154
|
-
def cherry_pick_from_test(self, commit_sha: str) -> bool:
|
|
155
|
-
try:
|
|
156
|
-
self.repo.git.cherry_pick(commit_sha)
|
|
157
|
-
success_line(f"Cherry-picked {commit_sha[:8]}")
|
|
158
|
-
return True
|
|
159
|
-
except GitCommandError as e:
|
|
160
|
-
error_line(f"Cherry-pick failed: {e}")
|
|
161
|
-
return False
|
|
162
|
-
|
|
163
|
-
def get_test_commits(self, limit: int = 20) -> list[dict]:
|
|
164
|
-
try:
|
|
165
|
-
self.repo.remotes.origin.fetch()
|
|
166
|
-
commits = list(self.repo.iter_commits("origin/test", max_count=limit))
|
|
167
|
-
return [{"sha": c.hexsha, "message": c.message.strip(), "author": str(c.author), "date": str(c.committed_datetime)} for c in commits]
|
|
168
|
-
except Exception:
|
|
169
|
-
return []
|
|
170
|
-
|
|
171
|
-
def prune_remote_branches(self) -> None:
|
|
172
|
-
self.repo.remotes.origin.fetch(prune=True)
|
|
173
|
-
success_line("Pruned remote branches")
|
|
174
|
-
|
|
175
|
-
def reset_to_main(self) -> None:
|
|
176
|
-
self.repo.remotes.origin.fetch()
|
|
177
|
-
self.repo.git.checkout("main")
|
|
178
|
-
self.repo.git.reset("--hard", "origin/main")
|
|
179
|
-
# Delete all local branches except main
|
|
180
|
-
for branch in self.repo.branches:
|
|
181
|
-
if branch.name != "main":
|
|
182
|
-
self.repo.delete_head(branch, force=True)
|
|
183
|
-
success_line("Reset to main completed")
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{datasecops_cli-0.4.8 → datasecops_cli-0.4.9}/src/datasecops_cli/services/bootstrap_service.py
RENAMED
|
File without changes
|
{datasecops_cli-0.4.8 → datasecops_cli-0.4.9}/src/datasecops_cli/services/dbt_project_generator.py
RENAMED
|
File without changes
|
|
File without changes
|
{datasecops_cli-0.4.8 → datasecops_cli-0.4.9}/src/datasecops_cli/services/directory_scaffolder.py
RENAMED
|
File without changes
|
{datasecops_cli-0.4.8 → datasecops_cli-0.4.9}/src/datasecops_cli/services/download_service.py
RENAMED
|
File without changes
|
{datasecops_cli-0.4.8 → datasecops_cli-0.4.9}/src/datasecops_cli/services/linting_service.py
RENAMED
|
File without changes
|
|
File without changes
|
{datasecops_cli-0.4.8 → datasecops_cli-0.4.9}/src/datasecops_cli/services/snowflake_service.py
RENAMED
|
File without changes
|
{datasecops_cli-0.4.8 → datasecops_cli-0.4.9}/src/datasecops_cli/services/upstream_service.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|