gitpush-tool 0.4.0__tar.gz → 0.4.1__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.
- {gitpush_tool-0.4.0 → gitpush_tool-0.4.1}/LONG_DESCRIPTION.md +35 -0
- {gitpush_tool-0.4.0/gitpush_tool.egg-info → gitpush_tool-0.4.1}/PKG-INFO +36 -1
- {gitpush_tool-0.4.0 → gitpush_tool-0.4.1}/README.md +37 -0
- {gitpush_tool-0.4.0 → gitpush_tool-0.4.1}/gitpush/__doc__.py +2 -1
- gitpush_tool-0.4.1/gitpush/__init__.py +1 -0
- {gitpush_tool-0.4.0 → gitpush_tool-0.4.1}/gitpush/cli.py +10 -1
- gitpush_tool-0.4.1/gitpush/status.py +369 -0
- {gitpush_tool-0.4.0 → gitpush_tool-0.4.1/gitpush_tool.egg-info}/PKG-INFO +36 -1
- {gitpush_tool-0.4.0 → gitpush_tool-0.4.1}/gitpush_tool.egg-info/SOURCES.txt +3 -1
- {gitpush_tool-0.4.0 → gitpush_tool-0.4.1}/gitpush_tool.egg-info/entry_points.txt +1 -0
- {gitpush_tool-0.4.0 → gitpush_tool-0.4.1}/setup.py +4 -7
- gitpush_tool-0.4.1/tests/test_cli.py +0 -0
- gitpush_tool-0.4.1/tests/test_safety.py +0 -0
- gitpush_tool-0.4.1/tests/test_status.py +113 -0
- gitpush_tool-0.4.0/gitpush/__init__.py +0 -1
- gitpush_tool-0.4.0/tests/test_cli.py +0 -25
- gitpush_tool-0.4.0/tests/test_safety.py +0 -169
- {gitpush_tool-0.4.0 → gitpush_tool-0.4.1}/LICENSE +0 -0
- {gitpush_tool-0.4.0 → gitpush_tool-0.4.1}/MANIFEST.in +0 -0
- {gitpush_tool-0.4.0 → gitpush_tool-0.4.1}/gitpush/safety.py +0 -0
- {gitpush_tool-0.4.0 → gitpush_tool-0.4.1}/gitpush_tool.egg-info/dependency_links.txt +0 -0
- {gitpush_tool-0.4.0 → gitpush_tool-0.4.1}/gitpush_tool.egg-info/top_level.txt +0 -0
- {gitpush_tool-0.4.0 → gitpush_tool-0.4.1}/pyproject.toml +0 -0
- {gitpush_tool-0.4.0 → gitpush_tool-0.4.1}/setup.cfg +0 -0
|
@@ -9,6 +9,7 @@ A supercharged Git CLI and safety tool that simplifies repository creation and p
|
|
|
9
9
|
|
|
10
10
|
## ✨ Features
|
|
11
11
|
|
|
12
|
+
- **Decision-Support Status Dashboard (`gitpush status`):** Actionable repository overview showing worktree breakdown, commit sync, intelligent next-step recommendations, and a 0-100 Repo Health score.
|
|
12
13
|
- **Pre-Push Safety & Diffstat Preview:** See changed files, line additions/deletions (`+X, -Y`), ahead/behind status, and pending commits before pushing.
|
|
13
14
|
- **Dangerous Git Operation Guard:** Analyzes and protects against risky actions (`reset --hard`, force pushes, deleting unmerged branches, dirty `git clean`, detached HEADs).
|
|
14
15
|
- **Protected Branch Safeguard:** Automatically detects and alerts when pushing or force-pushing to `main`, `master`, `prod`, `release`, or `dev`.
|
|
@@ -19,6 +20,40 @@ A supercharged Git CLI and safety tool that simplifies repository creation and p
|
|
|
19
20
|
|
|
20
21
|
---
|
|
21
22
|
|
|
23
|
+
## 📊 Decision-Support Status Dashboard (`gitpush status`)
|
|
24
|
+
|
|
25
|
+
```text
|
|
26
|
+
Repository: my-project
|
|
27
|
+
Branch: feature/payment
|
|
28
|
+
Tracking: origin/feature/payment
|
|
29
|
+
|
|
30
|
+
WORKTREE
|
|
31
|
+
────────────────────────
|
|
32
|
+
Modified 4
|
|
33
|
+
Added 2
|
|
34
|
+
Deleted 1
|
|
35
|
+
Untracked 3
|
|
36
|
+
Line Changes +142, -18
|
|
37
|
+
|
|
38
|
+
COMMITS
|
|
39
|
+
────────────────────────
|
|
40
|
+
Ahead 3
|
|
41
|
+
Behind 1
|
|
42
|
+
|
|
43
|
+
PUSH / STATUS
|
|
44
|
+
────────────────────────
|
|
45
|
+
⚠ Remote has 1 commit(s) you don't have locally.
|
|
46
|
+
|
|
47
|
+
Recommended:
|
|
48
|
+
git pull --rebase
|
|
49
|
+
gitpush "Commit message"
|
|
50
|
+
|
|
51
|
+
Health:
|
|
52
|
+
82/100 [████████░░] Good (Minor Action Needed)
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
---
|
|
56
|
+
|
|
22
57
|
## 🛡️ Pre-Push Warning & Inspection
|
|
23
58
|
|
|
24
59
|
Whenever you run `gitpush`, you get an instant, clear breakdown of your repository state:
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: gitpush-tool
|
|
3
|
-
Version: 0.4.
|
|
3
|
+
Version: 0.4.1
|
|
4
4
|
Summary: Supercharged Git push tool with automatic GitHub repo creation and pushing
|
|
5
5
|
Home-page: https://github.com/inevitablegs/gitpush
|
|
6
6
|
Author: Ganesh Sonawane
|
|
@@ -38,6 +38,7 @@ A supercharged Git CLI and safety tool that simplifies repository creation and p
|
|
|
38
38
|
|
|
39
39
|
## ✨ Features
|
|
40
40
|
|
|
41
|
+
- **Decision-Support Status Dashboard (`gitpush status`):** Actionable repository overview showing worktree breakdown, commit sync, intelligent next-step recommendations, and a 0-100 Repo Health score.
|
|
41
42
|
- **Pre-Push Safety & Diffstat Preview:** See changed files, line additions/deletions (`+X, -Y`), ahead/behind status, and pending commits before pushing.
|
|
42
43
|
- **Dangerous Git Operation Guard:** Analyzes and protects against risky actions (`reset --hard`, force pushes, deleting unmerged branches, dirty `git clean`, detached HEADs).
|
|
43
44
|
- **Protected Branch Safeguard:** Automatically detects and alerts when pushing or force-pushing to `main`, `master`, `prod`, `release`, or `dev`.
|
|
@@ -48,6 +49,40 @@ A supercharged Git CLI and safety tool that simplifies repository creation and p
|
|
|
48
49
|
|
|
49
50
|
---
|
|
50
51
|
|
|
52
|
+
## 📊 Decision-Support Status Dashboard (`gitpush status`)
|
|
53
|
+
|
|
54
|
+
```text
|
|
55
|
+
Repository: my-project
|
|
56
|
+
Branch: feature/payment
|
|
57
|
+
Tracking: origin/feature/payment
|
|
58
|
+
|
|
59
|
+
WORKTREE
|
|
60
|
+
────────────────────────
|
|
61
|
+
Modified 4
|
|
62
|
+
Added 2
|
|
63
|
+
Deleted 1
|
|
64
|
+
Untracked 3
|
|
65
|
+
Line Changes +142, -18
|
|
66
|
+
|
|
67
|
+
COMMITS
|
|
68
|
+
────────────────────────
|
|
69
|
+
Ahead 3
|
|
70
|
+
Behind 1
|
|
71
|
+
|
|
72
|
+
PUSH / STATUS
|
|
73
|
+
────────────────────────
|
|
74
|
+
⚠ Remote has 1 commit(s) you don't have locally.
|
|
75
|
+
|
|
76
|
+
Recommended:
|
|
77
|
+
git pull --rebase
|
|
78
|
+
gitpush "Commit message"
|
|
79
|
+
|
|
80
|
+
Health:
|
|
81
|
+
82/100 [████████░░] Good (Minor Action Needed)
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
---
|
|
85
|
+
|
|
51
86
|
## 🛡️ Pre-Push Warning & Inspection
|
|
52
87
|
|
|
53
88
|
Whenever you run `gitpush`, you get an instant, clear breakdown of your repository state:
|
|
@@ -9,6 +9,7 @@ A supercharged Git CLI and safety tool that simplifies repository creation and p
|
|
|
9
9
|
|
|
10
10
|
## ✨ Features
|
|
11
11
|
|
|
12
|
+
- **Decision-Support Status Dashboard (`gitpush status`):** Actionable repository overview showing worktree breakdown, commit sync, intelligent next-step recommendations, and a 0-100 Repo Health score.
|
|
12
13
|
- **Pre-Push Safety & Diffstat Preview:** See changed files, line additions/deletions (`+X, -Y`), ahead/behind status, and pending commits before pushing.
|
|
13
14
|
- **Dangerous Git Operation Guard:** Analyzes and protects against risky actions (`reset --hard`, force pushes, deleting unmerged branches, dirty `git clean`, detached HEADs).
|
|
14
15
|
- **Protected Branch Safeguard:** Automatically detects and alerts when pushing or force-pushing to `main`, `master`, `prod`, `release`, or `dev`.
|
|
@@ -27,6 +28,42 @@ pip install gitpush-tool
|
|
|
27
28
|
|
|
28
29
|
---
|
|
29
30
|
|
|
31
|
+
## 📊 Decision-Support Status Dashboard (`gitpush status`)
|
|
32
|
+
|
|
33
|
+
Instead of raw, cryptic Git output, `gitpush status` gives you structured decision support:
|
|
34
|
+
|
|
35
|
+
```text
|
|
36
|
+
Repository: my-project
|
|
37
|
+
Branch: feature/payment
|
|
38
|
+
Tracking: origin/feature/payment
|
|
39
|
+
|
|
40
|
+
WORKTREE
|
|
41
|
+
────────────────────────
|
|
42
|
+
Modified 4
|
|
43
|
+
Added 2
|
|
44
|
+
Deleted 1
|
|
45
|
+
Untracked 3
|
|
46
|
+
Line Changes +142, -18
|
|
47
|
+
|
|
48
|
+
COMMITS
|
|
49
|
+
────────────────────────
|
|
50
|
+
Ahead 3
|
|
51
|
+
Behind 1
|
|
52
|
+
|
|
53
|
+
PUSH / STATUS
|
|
54
|
+
────────────────────────
|
|
55
|
+
⚠ Remote has 1 commit(s) you don't have locally.
|
|
56
|
+
|
|
57
|
+
Recommended:
|
|
58
|
+
git pull --rebase
|
|
59
|
+
gitpush "Commit message"
|
|
60
|
+
|
|
61
|
+
Health:
|
|
62
|
+
82/100 [████████░░] Good (Minor Action Needed)
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
---
|
|
66
|
+
|
|
30
67
|
## 🛡️ Pre-Push Warning & Inspection
|
|
31
68
|
|
|
32
69
|
Whenever you run `gitpush`, you get an instant, clear breakdown of your repository state:
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
"""
|
|
2
|
-
GitPush Tool - Supercharged Git CLI (v0.4.
|
|
2
|
+
GitPush Tool - Supercharged Git CLI (v0.4.1)
|
|
3
3
|
|
|
4
4
|
A powerful command-line tool that simplifies Git operations with automatic GitHub
|
|
5
5
|
repository creation, pre-push diffstat inspection, and dangerous operation safeguards.
|
|
@@ -14,6 +14,7 @@ Key Features:
|
|
|
14
14
|
• GitHub CLI integration
|
|
15
15
|
|
|
16
16
|
Basic Usage:
|
|
17
|
+
gitpush status # Decision-support repository status
|
|
17
18
|
gitpush "Commit message" # Standard push with preview
|
|
18
19
|
gitpush "Commit message" -y # Push without confirmation prompt
|
|
19
20
|
gitpush --dry-run # Preview changes without pushing
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.4.1"
|
|
@@ -45,6 +45,8 @@ from .safety import (
|
|
|
45
45
|
Style
|
|
46
46
|
)
|
|
47
47
|
|
|
48
|
+
from .status import format_status_dashboard
|
|
49
|
+
|
|
48
50
|
|
|
49
51
|
# --- GitHub CLI Installation Orchestrator and Helpers ---
|
|
50
52
|
|
|
@@ -684,6 +686,12 @@ def handle_guard_command(raw_args: List[str], auto_confirm: bool = False) -> int
|
|
|
684
686
|
# --- Main Entry Point ---
|
|
685
687
|
|
|
686
688
|
def run():
|
|
689
|
+
# Handle status subcommand upfront if detected
|
|
690
|
+
if len(sys.argv) > 1 and sys.argv[1].lower() == "status":
|
|
691
|
+
verbose = "-v" in sys.argv or "--verbose" in sys.argv
|
|
692
|
+
print(format_status_dashboard(verbose=verbose))
|
|
693
|
+
sys.exit(0)
|
|
694
|
+
|
|
687
695
|
# Handle guard subcommand upfront if detected
|
|
688
696
|
if len(sys.argv) > 1 and sys.argv[1].lower() in ("guard", "check", "verify-danger"):
|
|
689
697
|
guard_args = sys.argv[2:]
|
|
@@ -692,9 +700,10 @@ def run():
|
|
|
692
700
|
sys.exit(handle_guard_command(clean_args, auto_confirm=auto_confirm))
|
|
693
701
|
|
|
694
702
|
parser = argparse.ArgumentParser(
|
|
695
|
-
description="🚀 Supercharged Git push tool with
|
|
703
|
+
description="🚀 Supercharged Git push tool with decision-support status dashboard and safety guards",
|
|
696
704
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
697
705
|
epilog="""Examples:
|
|
706
|
+
Decision status: gitpush status
|
|
698
707
|
Standard push: gitpush "My new feature"
|
|
699
708
|
Push without prompt: gitpush "My new feature" -y
|
|
700
709
|
Dry run preview: gitpush "My new feature" --dry-run
|
|
@@ -0,0 +1,369 @@
|
|
|
1
|
+
"""
|
|
2
|
+
GitPush Decision-Support Status Dashboard
|
|
3
|
+
Provides a rich, actionable overview of repository health, worktree breakdown,
|
|
4
|
+
commit sync state, and intelligent contextual recommendations.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import os
|
|
8
|
+
import sys
|
|
9
|
+
import subprocess
|
|
10
|
+
from typing import Optional, Dict, List, Tuple, Any
|
|
11
|
+
|
|
12
|
+
from .safety import (
|
|
13
|
+
is_git_repo,
|
|
14
|
+
get_repo_name,
|
|
15
|
+
get_repo_root,
|
|
16
|
+
get_current_branch,
|
|
17
|
+
is_detached_head,
|
|
18
|
+
is_protected_branch,
|
|
19
|
+
get_ahead_behind,
|
|
20
|
+
get_diffstat_summary,
|
|
21
|
+
run_git,
|
|
22
|
+
colorize,
|
|
23
|
+
Style
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def get_tracking_branch(cwd: Optional[str] = None) -> Optional[str]:
|
|
28
|
+
"""Get the upstream tracking branch for the current HEAD, e.g. origin/main."""
|
|
29
|
+
res = run_git(["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"], cwd=cwd)
|
|
30
|
+
if res.returncode == 0 and res.stdout.strip():
|
|
31
|
+
return res.stdout.strip()
|
|
32
|
+
return None
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def get_stash_count(cwd: Optional[str] = None) -> int:
|
|
36
|
+
"""Get count of stashes in the stash list."""
|
|
37
|
+
res = run_git(["stash", "list"], cwd=cwd)
|
|
38
|
+
if res.returncode == 0 and res.stdout.strip():
|
|
39
|
+
return len(res.stdout.strip().splitlines())
|
|
40
|
+
return 0
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def get_worktree_breakdown(cwd: Optional[str] = None) -> Dict[str, int]:
|
|
44
|
+
"""
|
|
45
|
+
Categorizes working tree changes into:
|
|
46
|
+
- modified
|
|
47
|
+
- added (staged new files)
|
|
48
|
+
- deleted
|
|
49
|
+
- untracked
|
|
50
|
+
- renamed
|
|
51
|
+
- unmerged (conflicts)
|
|
52
|
+
"""
|
|
53
|
+
counts = {
|
|
54
|
+
"modified": 0,
|
|
55
|
+
"added": 0,
|
|
56
|
+
"deleted": 0,
|
|
57
|
+
"untracked": 0,
|
|
58
|
+
"renamed": 0,
|
|
59
|
+
"unmerged": 0
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
res = run_git(["status", "--porcelain=v1", "-uall"], cwd=cwd)
|
|
63
|
+
if res.returncode != 0 or not res.stdout.strip():
|
|
64
|
+
return counts
|
|
65
|
+
|
|
66
|
+
for line in res.stdout.splitlines():
|
|
67
|
+
if len(line) < 3:
|
|
68
|
+
continue
|
|
69
|
+
index_code = line[0]
|
|
70
|
+
work_code = line[1]
|
|
71
|
+
|
|
72
|
+
# Check unmerged / conflicts
|
|
73
|
+
if index_code in ('U', 'A', 'D') and work_code == 'U' or (index_code == 'U' and work_code in ('U', 'A', 'D')) or (index_code == 'A' and work_code == 'A') or (index_code == 'D' and work_code == 'D'):
|
|
74
|
+
counts["unmerged"] += 1
|
|
75
|
+
continue
|
|
76
|
+
|
|
77
|
+
# Untracked
|
|
78
|
+
if index_code == '?' and work_code == '?':
|
|
79
|
+
counts["untracked"] += 1
|
|
80
|
+
continue
|
|
81
|
+
|
|
82
|
+
# Added / New in index
|
|
83
|
+
if index_code == 'A':
|
|
84
|
+
counts["added"] += 1
|
|
85
|
+
if work_code == 'M':
|
|
86
|
+
counts["modified"] += 1
|
|
87
|
+
elif work_code == 'D':
|
|
88
|
+
counts["deleted"] += 1
|
|
89
|
+
continue
|
|
90
|
+
|
|
91
|
+
# Deleted
|
|
92
|
+
if index_code == 'D' or work_code == 'D':
|
|
93
|
+
counts["deleted"] += 1
|
|
94
|
+
continue
|
|
95
|
+
|
|
96
|
+
# Renamed
|
|
97
|
+
if index_code == 'R' or work_code == 'R':
|
|
98
|
+
counts["renamed"] += 1
|
|
99
|
+
continue
|
|
100
|
+
|
|
101
|
+
# Modified
|
|
102
|
+
if index_code == 'M' or work_code == 'M':
|
|
103
|
+
counts["modified"] += 1
|
|
104
|
+
continue
|
|
105
|
+
|
|
106
|
+
return counts
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def calculate_repo_health(
|
|
110
|
+
detached: bool,
|
|
111
|
+
unmerged_count: int,
|
|
112
|
+
behind_count: int,
|
|
113
|
+
diverged: bool,
|
|
114
|
+
modified_count: int,
|
|
115
|
+
untracked_count: int,
|
|
116
|
+
stash_count: int,
|
|
117
|
+
is_protected: bool
|
|
118
|
+
) -> Tuple[int, str, str]:
|
|
119
|
+
"""
|
|
120
|
+
Computes a repository health score between 0 and 100 with a label and color style.
|
|
121
|
+
Returns: (score, label, color_style)
|
|
122
|
+
"""
|
|
123
|
+
score = 100
|
|
124
|
+
|
|
125
|
+
# Severe issues
|
|
126
|
+
if unmerged_count > 0:
|
|
127
|
+
score -= min(40, unmerged_count * 20)
|
|
128
|
+
if detached:
|
|
129
|
+
score -= 25
|
|
130
|
+
if diverged:
|
|
131
|
+
score -= 20
|
|
132
|
+
elif behind_count > 0:
|
|
133
|
+
score -= min(15, behind_count * 3)
|
|
134
|
+
|
|
135
|
+
# Worktree hygiene
|
|
136
|
+
total_dirty = modified_count + untracked_count
|
|
137
|
+
if total_dirty > 20:
|
|
138
|
+
score -= 15
|
|
139
|
+
elif total_dirty > 10:
|
|
140
|
+
score -= 10
|
|
141
|
+
elif total_dirty > 5:
|
|
142
|
+
score -= 5
|
|
143
|
+
|
|
144
|
+
# Stash buildup
|
|
145
|
+
if stash_count > 10:
|
|
146
|
+
score -= 10
|
|
147
|
+
elif stash_count > 5:
|
|
148
|
+
score -= 5
|
|
149
|
+
|
|
150
|
+
# Clamp between 0 and 100
|
|
151
|
+
score = max(0, min(100, score))
|
|
152
|
+
|
|
153
|
+
if score >= 90:
|
|
154
|
+
label = "Excellent (Clean & Ready)"
|
|
155
|
+
color = Style.GREEN
|
|
156
|
+
elif score >= 75:
|
|
157
|
+
label = "Good (Minor Action Needed)"
|
|
158
|
+
color = Style.CYAN
|
|
159
|
+
elif score >= 50:
|
|
160
|
+
label = "Attention Needed"
|
|
161
|
+
color = Style.YELLOW
|
|
162
|
+
else:
|
|
163
|
+
label = "Critical (Conflicts / Detached)"
|
|
164
|
+
color = Style.RED
|
|
165
|
+
|
|
166
|
+
return score, label, color
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def get_decision_recommendations(
|
|
170
|
+
branch: str,
|
|
171
|
+
tracking: Optional[str],
|
|
172
|
+
ahead: int,
|
|
173
|
+
behind: int,
|
|
174
|
+
diverged: bool,
|
|
175
|
+
detached: bool,
|
|
176
|
+
worktree: Dict[str, int],
|
|
177
|
+
is_protected: bool
|
|
178
|
+
) -> Tuple[List[str], List[str]]:
|
|
179
|
+
"""
|
|
180
|
+
Generates actionable, contextual push/pull notes and recommended next commands.
|
|
181
|
+
Returns: (status_notes, recommended_commands)
|
|
182
|
+
"""
|
|
183
|
+
status_notes = []
|
|
184
|
+
recommended = []
|
|
185
|
+
|
|
186
|
+
total_worktree_changes = sum(worktree.values())
|
|
187
|
+
|
|
188
|
+
# 1. Unmerged / Conflicts
|
|
189
|
+
if worktree.get("unmerged", 0) > 0:
|
|
190
|
+
status_notes.append(colorize(f"❌ {worktree['unmerged']} merge conflict(s) require resolution.", Style.BOLD, Style.RED))
|
|
191
|
+
recommended.append("Resolve conflict markers in affected files, then run: git add .")
|
|
192
|
+
recommended.append("Continue rebase/merge with: git rebase --continue (or git commit)")
|
|
193
|
+
return status_notes, recommended
|
|
194
|
+
|
|
195
|
+
# 2. Detached HEAD
|
|
196
|
+
if detached:
|
|
197
|
+
status_notes.append(colorize("⚠ You are in a detached HEAD state. Commits made here are not on a branch.", Style.YELLOW))
|
|
198
|
+
recommended.append("Create a new branch to keep your work: git switch -c feature/my-new-branch")
|
|
199
|
+
return status_notes, recommended
|
|
200
|
+
|
|
201
|
+
# 3. Remote Tracking & Sync
|
|
202
|
+
if tracking is None:
|
|
203
|
+
status_notes.append("Branch has no remote tracking branch set.")
|
|
204
|
+
if total_worktree_changes > 0:
|
|
205
|
+
recommended.append(f'gitpush "Commit message" {branch} origin')
|
|
206
|
+
else:
|
|
207
|
+
recommended.append(f"git push -u origin {branch}")
|
|
208
|
+
elif diverged:
|
|
209
|
+
status_notes.append(colorize(f"⚠ Branch has diverged from '{tracking}' ({ahead} ahead, {behind} behind).", Style.YELLOW))
|
|
210
|
+
recommended.append("Rebase onto remote changes: git pull --rebase")
|
|
211
|
+
recommended.append(f'Then push your changes: gitpush')
|
|
212
|
+
elif behind > 0:
|
|
213
|
+
status_notes.append(colorize(f"⚠ Remote has {behind} commit(s) you don't have locally.", Style.YELLOW))
|
|
214
|
+
recommended.append("git pull --rebase")
|
|
215
|
+
if ahead > 0 or total_worktree_changes > 0:
|
|
216
|
+
recommended.append(f'gitpush "Commit message"')
|
|
217
|
+
elif ahead > 0:
|
|
218
|
+
if total_worktree_changes > 0:
|
|
219
|
+
status_notes.append(f"Ready to commit {total_worktree_changes} change(s) and push {ahead} commit(s).")
|
|
220
|
+
recommended.append(f'gitpush "Commit message"')
|
|
221
|
+
else:
|
|
222
|
+
status_notes.append(colorize(f"✔ Ready to push {ahead} local commit(s) to '{tracking}'.", Style.GREEN))
|
|
223
|
+
recommended.append("gitpush")
|
|
224
|
+
else:
|
|
225
|
+
# Synced with remote
|
|
226
|
+
if total_worktree_changes > 0:
|
|
227
|
+
status_notes.append(f"You have {total_worktree_changes} uncommitted file change(s).")
|
|
228
|
+
recommended.append(f'gitpush "Describe your changes"')
|
|
229
|
+
else:
|
|
230
|
+
status_notes.append(colorize("✔ Repository is clean and in sync with remote.", Style.GREEN))
|
|
231
|
+
recommended.append("No action needed (all changes committed and pushed).")
|
|
232
|
+
|
|
233
|
+
# Protected branch advice
|
|
234
|
+
if is_protected and (ahead > 0 or total_worktree_changes > 0):
|
|
235
|
+
status_notes.append(colorize(f"ℹ '{branch}' is a protected branch.", Style.CYAN))
|
|
236
|
+
if not any("git switch -c" in r for r in recommended):
|
|
237
|
+
recommended.append(f"Tip: If contributing a feature, branch off: git switch -c feature/new-idea")
|
|
238
|
+
|
|
239
|
+
return status_notes, recommended
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def format_status_dashboard(cwd: Optional[str] = None, verbose: bool = False) -> str:
|
|
243
|
+
"""
|
|
244
|
+
Format the complete decision-support status dashboard.
|
|
245
|
+
"""
|
|
246
|
+
if not is_git_repo(cwd):
|
|
247
|
+
return colorize("❌ Not a Git repository (or any of the parent directories).", Style.RED)
|
|
248
|
+
|
|
249
|
+
repo_name = get_repo_name(cwd)
|
|
250
|
+
branch = get_current_branch(cwd)
|
|
251
|
+
detached = is_detached_head(cwd)
|
|
252
|
+
tracking = get_tracking_branch(cwd)
|
|
253
|
+
is_prot = is_protected_branch(branch)
|
|
254
|
+
|
|
255
|
+
# Remote sync status
|
|
256
|
+
remote = "origin"
|
|
257
|
+
remote_branch = branch
|
|
258
|
+
if tracking and "/" in tracking:
|
|
259
|
+
remote, remote_branch = tracking.split("/", 1)
|
|
260
|
+
|
|
261
|
+
behind, ahead = get_ahead_behind(remote, remote_branch, cwd)
|
|
262
|
+
diverged = (ahead > 0 and behind > 0)
|
|
263
|
+
|
|
264
|
+
# Worktree breakdown
|
|
265
|
+
worktree = get_worktree_breakdown(cwd)
|
|
266
|
+
stash_count = get_stash_count(cwd)
|
|
267
|
+
diff_summary = get_diffstat_summary(cwd)
|
|
268
|
+
|
|
269
|
+
# Health score
|
|
270
|
+
score, health_label, health_color = calculate_repo_health(
|
|
271
|
+
detached=detached,
|
|
272
|
+
unmerged_count=worktree.get("unmerged", 0),
|
|
273
|
+
behind_count=behind,
|
|
274
|
+
diverged=diverged,
|
|
275
|
+
modified_count=worktree["modified"],
|
|
276
|
+
untracked_count=worktree["untracked"],
|
|
277
|
+
stash_count=stash_count,
|
|
278
|
+
is_protected=is_prot
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
# Decision recommendations
|
|
282
|
+
status_notes, recommendations = get_decision_recommendations(
|
|
283
|
+
branch=branch,
|
|
284
|
+
tracking=tracking,
|
|
285
|
+
ahead=ahead,
|
|
286
|
+
behind=behind,
|
|
287
|
+
diverged=diverged,
|
|
288
|
+
detached=detached,
|
|
289
|
+
worktree=worktree,
|
|
290
|
+
is_protected=is_prot
|
|
291
|
+
)
|
|
292
|
+
|
|
293
|
+
lines = []
|
|
294
|
+
lines.append("")
|
|
295
|
+
lines.append(f"{colorize('Repository:', Style.BOLD)} {colorize(repo_name, Style.GREEN)}")
|
|
296
|
+
|
|
297
|
+
branch_styled = branch
|
|
298
|
+
if detached:
|
|
299
|
+
branch_styled = colorize(f"{branch} [DETACHED HEAD ⚠️]", Style.BOLD, Style.RED)
|
|
300
|
+
elif is_prot:
|
|
301
|
+
branch_styled = colorize(f"{branch} [Protected]", Style.BOLD, Style.YELLOW)
|
|
302
|
+
else:
|
|
303
|
+
branch_styled = colorize(branch, Style.CYAN)
|
|
304
|
+
|
|
305
|
+
lines.append(f"{colorize('Branch:', Style.BOLD)} {branch_styled}")
|
|
306
|
+
tracking_str = tracking if tracking else colorize("None (no tracking remote)", Style.DIM)
|
|
307
|
+
lines.append(f"{colorize('Tracking:', Style.BOLD)} {tracking_str}")
|
|
308
|
+
lines.append("")
|
|
309
|
+
|
|
310
|
+
# WORKTREE section
|
|
311
|
+
lines.append(colorize("WORKTREE", Style.BOLD))
|
|
312
|
+
lines.append(colorize("────────────────────────", Style.DIM))
|
|
313
|
+
total_worktree = sum(worktree.values())
|
|
314
|
+
if total_worktree == 0:
|
|
315
|
+
lines.append(colorize("Clean (no changes)", Style.DIM))
|
|
316
|
+
else:
|
|
317
|
+
if worktree["modified"] > 0:
|
|
318
|
+
lines.append(f"Modified {colorize(str(worktree['modified']), Style.CYAN)}")
|
|
319
|
+
if worktree["added"] > 0:
|
|
320
|
+
lines.append(f"Added {colorize(str(worktree['added']), Style.GREEN)}")
|
|
321
|
+
if worktree["deleted"] > 0:
|
|
322
|
+
lines.append(f"Deleted {colorize(str(worktree['deleted']), Style.RED)}")
|
|
323
|
+
if worktree["renamed"] > 0:
|
|
324
|
+
lines.append(f"Renamed {colorize(str(worktree['renamed']), Style.CYAN)}")
|
|
325
|
+
if worktree["untracked"] > 0:
|
|
326
|
+
lines.append(f"Untracked {colorize(str(worktree['untracked']), Style.YELLOW)}")
|
|
327
|
+
if worktree["unmerged"] > 0:
|
|
328
|
+
lines.append(f"Conflicts {colorize(str(worktree['unmerged']), Style.BOLD, Style.RED)}")
|
|
329
|
+
|
|
330
|
+
# Line stat addition/deletion
|
|
331
|
+
if diff_summary["total_additions"] > 0 or diff_summary["total_deletions"] > 0:
|
|
332
|
+
adds = colorize(f"+{diff_summary['total_additions']}", Style.GREEN)
|
|
333
|
+
dels = colorize(f"-{diff_summary['total_deletions']}", Style.RED)
|
|
334
|
+
lines.append(f"Line Changes {adds}, {dels}")
|
|
335
|
+
|
|
336
|
+
if stash_count > 0:
|
|
337
|
+
lines.append(f"Stashes {stash_count} saved")
|
|
338
|
+
lines.append("")
|
|
339
|
+
|
|
340
|
+
# COMMITS section
|
|
341
|
+
lines.append(colorize("COMMITS", Style.BOLD))
|
|
342
|
+
lines.append(colorize("────────────────────────", Style.DIM))
|
|
343
|
+
ahead_str = colorize(str(ahead), Style.GREEN) if ahead > 0 else "0"
|
|
344
|
+
behind_str = colorize(str(behind), Style.RED) if behind > 0 else "0"
|
|
345
|
+
lines.append(f"Ahead {ahead_str}")
|
|
346
|
+
lines.append(f"Behind {behind_str}")
|
|
347
|
+
lines.append("")
|
|
348
|
+
|
|
349
|
+
# PUSH / PULL section (Decision Support)
|
|
350
|
+
lines.append(colorize("PUSH / STATUS", Style.BOLD))
|
|
351
|
+
lines.append(colorize("────────────────────────", Style.DIM))
|
|
352
|
+
for note in status_notes:
|
|
353
|
+
lines.append(note)
|
|
354
|
+
lines.append("")
|
|
355
|
+
|
|
356
|
+
# Recommended Action
|
|
357
|
+
lines.append(colorize("Recommended:", Style.BOLD))
|
|
358
|
+
for rec in recommendations:
|
|
359
|
+
lines.append(f" {colorize(rec, Style.CYAN)}")
|
|
360
|
+
lines.append("")
|
|
361
|
+
|
|
362
|
+
# Repository Health Score
|
|
363
|
+
health_bar_filled = int(score / 10)
|
|
364
|
+
health_bar = f"[{'█' * health_bar_filled}{'░' * (10 - health_bar_filled)}]"
|
|
365
|
+
lines.append(colorize("Health:", Style.BOLD))
|
|
366
|
+
lines.append(f" {colorize(f'{score}/100', Style.BOLD, health_color)} {colorize(health_bar, health_color)} {colorize(health_label, Style.DIM)}")
|
|
367
|
+
lines.append("")
|
|
368
|
+
|
|
369
|
+
return "\n".join(lines)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: gitpush-tool
|
|
3
|
-
Version: 0.4.
|
|
3
|
+
Version: 0.4.1
|
|
4
4
|
Summary: Supercharged Git push tool with automatic GitHub repo creation and pushing
|
|
5
5
|
Home-page: https://github.com/inevitablegs/gitpush
|
|
6
6
|
Author: Ganesh Sonawane
|
|
@@ -38,6 +38,7 @@ A supercharged Git CLI and safety tool that simplifies repository creation and p
|
|
|
38
38
|
|
|
39
39
|
## ✨ Features
|
|
40
40
|
|
|
41
|
+
- **Decision-Support Status Dashboard (`gitpush status`):** Actionable repository overview showing worktree breakdown, commit sync, intelligent next-step recommendations, and a 0-100 Repo Health score.
|
|
41
42
|
- **Pre-Push Safety & Diffstat Preview:** See changed files, line additions/deletions (`+X, -Y`), ahead/behind status, and pending commits before pushing.
|
|
42
43
|
- **Dangerous Git Operation Guard:** Analyzes and protects against risky actions (`reset --hard`, force pushes, deleting unmerged branches, dirty `git clean`, detached HEADs).
|
|
43
44
|
- **Protected Branch Safeguard:** Automatically detects and alerts when pushing or force-pushing to `main`, `master`, `prod`, `release`, or `dev`.
|
|
@@ -48,6 +49,40 @@ A supercharged Git CLI and safety tool that simplifies repository creation and p
|
|
|
48
49
|
|
|
49
50
|
---
|
|
50
51
|
|
|
52
|
+
## 📊 Decision-Support Status Dashboard (`gitpush status`)
|
|
53
|
+
|
|
54
|
+
```text
|
|
55
|
+
Repository: my-project
|
|
56
|
+
Branch: feature/payment
|
|
57
|
+
Tracking: origin/feature/payment
|
|
58
|
+
|
|
59
|
+
WORKTREE
|
|
60
|
+
────────────────────────
|
|
61
|
+
Modified 4
|
|
62
|
+
Added 2
|
|
63
|
+
Deleted 1
|
|
64
|
+
Untracked 3
|
|
65
|
+
Line Changes +142, -18
|
|
66
|
+
|
|
67
|
+
COMMITS
|
|
68
|
+
────────────────────────
|
|
69
|
+
Ahead 3
|
|
70
|
+
Behind 1
|
|
71
|
+
|
|
72
|
+
PUSH / STATUS
|
|
73
|
+
────────────────────────
|
|
74
|
+
⚠ Remote has 1 commit(s) you don't have locally.
|
|
75
|
+
|
|
76
|
+
Recommended:
|
|
77
|
+
git pull --rebase
|
|
78
|
+
gitpush "Commit message"
|
|
79
|
+
|
|
80
|
+
Health:
|
|
81
|
+
82/100 [████████░░] Good (Minor Action Needed)
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
---
|
|
85
|
+
|
|
51
86
|
## 🛡️ Pre-Push Warning & Inspection
|
|
52
87
|
|
|
53
88
|
Whenever you run `gitpush`, you get an instant, clear breakdown of your repository state:
|
|
@@ -8,10 +8,12 @@ gitpush/__doc__.py
|
|
|
8
8
|
gitpush/__init__.py
|
|
9
9
|
gitpush/cli.py
|
|
10
10
|
gitpush/safety.py
|
|
11
|
+
gitpush/status.py
|
|
11
12
|
gitpush_tool.egg-info/PKG-INFO
|
|
12
13
|
gitpush_tool.egg-info/SOURCES.txt
|
|
13
14
|
gitpush_tool.egg-info/dependency_links.txt
|
|
14
15
|
gitpush_tool.egg-info/entry_points.txt
|
|
15
16
|
gitpush_tool.egg-info/top_level.txt
|
|
16
17
|
tests/test_cli.py
|
|
17
|
-
tests/test_safety.py
|
|
18
|
+
tests/test_safety.py
|
|
19
|
+
tests/test_status.py
|
|
@@ -2,21 +2,18 @@ from setuptools import setup, find_packages
|
|
|
2
2
|
from pathlib import Path
|
|
3
3
|
|
|
4
4
|
# Read long description
|
|
5
|
-
|
|
6
|
-
if not long_desc_file.exists():
|
|
7
|
-
long_desc_file = Path(__file__).parent / "README.md"
|
|
8
|
-
long_description = long_desc_file.read_text(encoding="utf-8") if long_desc_file.exists() else ""
|
|
9
|
-
|
|
5
|
+
long_description = (Path(__file__).parent / "LONG_DESCRIPTION.md").read_text(encoding="utf-8")
|
|
10
6
|
|
|
11
7
|
setup(
|
|
12
8
|
name="gitpush-tool",
|
|
13
|
-
version="0.4.
|
|
9
|
+
version="0.4.1",
|
|
14
10
|
packages=find_packages(),
|
|
15
11
|
install_requires=[],
|
|
16
12
|
entry_points={
|
|
17
13
|
"console_scripts": [
|
|
18
14
|
"gitpush=gitpush.cli:run",
|
|
19
|
-
"gitpush-guard=gitpush.cli:run"
|
|
15
|
+
"gitpush-guard=gitpush.cli:run",
|
|
16
|
+
"gitpush-status=gitpush.cli:run"
|
|
20
17
|
],
|
|
21
18
|
},
|
|
22
19
|
author="Ganesh Sonawane",
|
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import unittest
|
|
2
|
+
import os
|
|
3
|
+
import sys
|
|
4
|
+
import tempfile
|
|
5
|
+
import shutil
|
|
6
|
+
import subprocess
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
# Add package root to sys.path
|
|
10
|
+
sys.path.insert(0, str(Path(__file__).parent.parent))
|
|
11
|
+
|
|
12
|
+
from gitpush.status import (
|
|
13
|
+
get_tracking_branch,
|
|
14
|
+
get_stash_count,
|
|
15
|
+
get_worktree_breakdown,
|
|
16
|
+
calculate_repo_health,
|
|
17
|
+
get_decision_recommendations,
|
|
18
|
+
format_status_dashboard
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class TestStatusDashboard(unittest.TestCase):
|
|
23
|
+
|
|
24
|
+
def setUp(self):
|
|
25
|
+
self.test_dir = tempfile.mkdtemp()
|
|
26
|
+
subprocess.run(["git", "init"], cwd=self.test_dir, capture_output=True, check=True)
|
|
27
|
+
subprocess.run(["git", "config", "user.name", "Test User"], cwd=self.test_dir, capture_output=True, check=True)
|
|
28
|
+
subprocess.run(["git", "config", "user.email", "test@example.com"], cwd=self.test_dir, capture_output=True, check=True)
|
|
29
|
+
subprocess.run(["git", "branch", "-M", "feature/payment"], cwd=self.test_dir, capture_output=True, check=True)
|
|
30
|
+
|
|
31
|
+
# Initial commit
|
|
32
|
+
init_file = os.path.join(self.test_dir, "init.txt")
|
|
33
|
+
with open(init_file, "w") as f:
|
|
34
|
+
f.write("init\n")
|
|
35
|
+
subprocess.run(["git", "add", "."], cwd=self.test_dir, capture_output=True, check=True)
|
|
36
|
+
subprocess.run(["git", "commit", "-m", "initial commit"], cwd=self.test_dir, capture_output=True, check=True)
|
|
37
|
+
|
|
38
|
+
def tearDown(self):
|
|
39
|
+
shutil.rmtree(self.test_dir, ignore_errors=True)
|
|
40
|
+
|
|
41
|
+
def test_worktree_breakdown(self):
|
|
42
|
+
# Create modified, added, untracked, deleted files
|
|
43
|
+
f1 = os.path.join(self.test_dir, "init.txt")
|
|
44
|
+
with open(f1, "w") as f:
|
|
45
|
+
f.write("init modified\n")
|
|
46
|
+
|
|
47
|
+
f2 = os.path.join(self.test_dir, "new_staged.txt")
|
|
48
|
+
with open(f2, "w") as f:
|
|
49
|
+
f.write("staged\n")
|
|
50
|
+
subprocess.run(["git", "add", "new_staged.txt"], cwd=self.test_dir, check=True)
|
|
51
|
+
|
|
52
|
+
f3 = os.path.join(self.test_dir, "untracked.txt")
|
|
53
|
+
with open(f3, "w") as f:
|
|
54
|
+
f.write("untracked\n")
|
|
55
|
+
|
|
56
|
+
counts = get_worktree_breakdown(cwd=self.test_dir)
|
|
57
|
+
self.assertEqual(counts["modified"], 1)
|
|
58
|
+
self.assertEqual(counts["added"], 1)
|
|
59
|
+
self.assertEqual(counts["untracked"], 1)
|
|
60
|
+
|
|
61
|
+
def test_health_calculation(self):
|
|
62
|
+
score_clean, label_clean, _ = calculate_repo_health(
|
|
63
|
+
detached=False,
|
|
64
|
+
unmerged_count=0,
|
|
65
|
+
behind_count=0,
|
|
66
|
+
diverged=False,
|
|
67
|
+
modified_count=0,
|
|
68
|
+
untracked_count=0,
|
|
69
|
+
stash_count=0,
|
|
70
|
+
is_protected=False
|
|
71
|
+
)
|
|
72
|
+
self.assertEqual(score_clean, 100)
|
|
73
|
+
self.assertIn("Excellent", label_clean)
|
|
74
|
+
|
|
75
|
+
score_dirty, label_dirty, _ = calculate_repo_health(
|
|
76
|
+
detached=True,
|
|
77
|
+
unmerged_count=1,
|
|
78
|
+
behind_count=3,
|
|
79
|
+
diverged=True,
|
|
80
|
+
modified_count=6,
|
|
81
|
+
untracked_count=6,
|
|
82
|
+
stash_count=6,
|
|
83
|
+
is_protected=True
|
|
84
|
+
)
|
|
85
|
+
self.assertTrue(score_dirty < 50)
|
|
86
|
+
|
|
87
|
+
def test_decision_recommendations(self):
|
|
88
|
+
# Test behind remote recommendation
|
|
89
|
+
notes, recs = get_decision_recommendations(
|
|
90
|
+
branch="feature/payment",
|
|
91
|
+
tracking="origin/feature/payment",
|
|
92
|
+
ahead=0,
|
|
93
|
+
behind=2,
|
|
94
|
+
diverged=False,
|
|
95
|
+
detached=False,
|
|
96
|
+
worktree={"modified": 0, "added": 0, "deleted": 0, "untracked": 0, "renamed": 0, "unmerged": 0},
|
|
97
|
+
is_protected=False
|
|
98
|
+
)
|
|
99
|
+
self.assertTrue(any("git pull --rebase" in r for r in recs))
|
|
100
|
+
self.assertTrue(any("Remote has" in n for n in notes))
|
|
101
|
+
|
|
102
|
+
def test_format_status_dashboard(self):
|
|
103
|
+
dashboard = format_status_dashboard(cwd=self.test_dir)
|
|
104
|
+
self.assertIn("Repository:", dashboard)
|
|
105
|
+
self.assertIn("feature/payment", dashboard)
|
|
106
|
+
self.assertIn("WORKTREE", dashboard)
|
|
107
|
+
self.assertIn("COMMITS", dashboard)
|
|
108
|
+
self.assertIn("Health:", dashboard)
|
|
109
|
+
self.assertIn("Recommended:", dashboard)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
if __name__ == "__main__":
|
|
113
|
+
unittest.main()
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
__version__ = "0.4.0"
|
|
@@ -1,25 +0,0 @@
|
|
|
1
|
-
import unittest
|
|
2
|
-
import sys
|
|
3
|
-
import subprocess
|
|
4
|
-
from pathlib import Path
|
|
5
|
-
|
|
6
|
-
# Add package root to sys.path
|
|
7
|
-
sys.path.insert(0, str(Path(__file__).parent.parent))
|
|
8
|
-
|
|
9
|
-
from gitpush.cli import handle_guard_command
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
class TestCLI(unittest.TestCase):
|
|
13
|
-
|
|
14
|
-
def test_guard_help_when_no_args(self):
|
|
15
|
-
code = handle_guard_command([])
|
|
16
|
-
self.assertEqual(code, 1)
|
|
17
|
-
|
|
18
|
-
def test_guard_dry_check(self):
|
|
19
|
-
# Run git status through guard with -y
|
|
20
|
-
code = handle_guard_command(["status"], auto_confirm=True)
|
|
21
|
-
self.assertEqual(code, 0)
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
if __name__ == "__main__":
|
|
25
|
-
unittest.main()
|
|
@@ -1,169 +0,0 @@
|
|
|
1
|
-
import unittest
|
|
2
|
-
import os
|
|
3
|
-
import sys
|
|
4
|
-
import tempfile
|
|
5
|
-
import shutil
|
|
6
|
-
import subprocess
|
|
7
|
-
from pathlib import Path
|
|
8
|
-
|
|
9
|
-
# Add package root to sys.path
|
|
10
|
-
sys.path.insert(0, str(Path(__file__).parent.parent))
|
|
11
|
-
|
|
12
|
-
from gitpush.safety import (
|
|
13
|
-
is_git_repo,
|
|
14
|
-
get_repo_name,
|
|
15
|
-
get_repo_root,
|
|
16
|
-
get_current_branch,
|
|
17
|
-
is_detached_head,
|
|
18
|
-
is_protected_branch,
|
|
19
|
-
get_ahead_behind,
|
|
20
|
-
get_diffstat_summary,
|
|
21
|
-
DangerousOperationAnalyzer,
|
|
22
|
-
format_danger_box,
|
|
23
|
-
format_prepush_summary
|
|
24
|
-
)
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
class TestSafetyEngine(unittest.TestCase):
|
|
28
|
-
|
|
29
|
-
def setUp(self):
|
|
30
|
-
# Create a temporary directory and initialize a git repo for testing
|
|
31
|
-
self.test_dir = tempfile.mkdtemp()
|
|
32
|
-
subprocess.run(["git", "init"], cwd=self.test_dir, capture_output=True, check=True)
|
|
33
|
-
subprocess.run(["git", "config", "user.name", "Test User"], cwd=self.test_dir, capture_output=True, check=True)
|
|
34
|
-
subprocess.run(["git", "config", "user.email", "test@example.com"], cwd=self.test_dir, capture_output=True, check=True)
|
|
35
|
-
subprocess.run(["git", "branch", "-M", "main"], cwd=self.test_dir, capture_output=True, check=True)
|
|
36
|
-
# Create initial root commit
|
|
37
|
-
init_file = os.path.join(self.test_dir, "init.txt")
|
|
38
|
-
with open(init_file, "w") as f:
|
|
39
|
-
f.write("init\n")
|
|
40
|
-
subprocess.run(["git", "add", "."], cwd=self.test_dir, capture_output=True, check=True)
|
|
41
|
-
subprocess.run(["git", "commit", "-m", "initial commit"], cwd=self.test_dir, capture_output=True, check=True)
|
|
42
|
-
|
|
43
|
-
def tearDown(self):
|
|
44
|
-
shutil.rmtree(self.test_dir, ignore_errors=True)
|
|
45
|
-
|
|
46
|
-
def test_is_git_repo(self):
|
|
47
|
-
self.assertTrue(is_git_repo(cwd=self.test_dir))
|
|
48
|
-
non_repo = tempfile.mkdtemp()
|
|
49
|
-
try:
|
|
50
|
-
self.assertFalse(is_git_repo(cwd=non_repo))
|
|
51
|
-
finally:
|
|
52
|
-
shutil.rmtree(non_repo, ignore_errors=True)
|
|
53
|
-
|
|
54
|
-
def test_repo_name_and_root(self):
|
|
55
|
-
root = get_repo_root(cwd=self.test_dir)
|
|
56
|
-
self.assertIsNotNone(root)
|
|
57
|
-
self.assertEqual(os.path.abspath(root), os.path.abspath(self.test_dir))
|
|
58
|
-
name = get_repo_name(cwd=self.test_dir)
|
|
59
|
-
self.assertEqual(name, os.path.basename(self.test_dir))
|
|
60
|
-
|
|
61
|
-
def test_protected_branch_detection(self):
|
|
62
|
-
self.assertTrue(is_protected_branch("main"))
|
|
63
|
-
self.assertTrue(is_protected_branch("master"))
|
|
64
|
-
self.assertTrue(is_protected_branch("prod"))
|
|
65
|
-
self.assertTrue(is_protected_branch("production"))
|
|
66
|
-
self.assertTrue(is_protected_branch("release"))
|
|
67
|
-
self.assertTrue(is_protected_branch("dev"))
|
|
68
|
-
self.assertFalse(is_protected_branch("feature/login"))
|
|
69
|
-
self.assertFalse(is_protected_branch("bugfix-123"))
|
|
70
|
-
|
|
71
|
-
def test_diffstat_summary(self):
|
|
72
|
-
# Initially clean
|
|
73
|
-
diff = get_diffstat_summary(cwd=self.test_dir)
|
|
74
|
-
self.assertFalse(diff["has_changes"])
|
|
75
|
-
|
|
76
|
-
# Create a file
|
|
77
|
-
f1 = os.path.join(self.test_dir, "hello.txt")
|
|
78
|
-
with open(f1, "w") as f:
|
|
79
|
-
f.write("Line 1\nLine 2\nLine 3\n")
|
|
80
|
-
|
|
81
|
-
diff = get_diffstat_summary(cwd=self.test_dir)
|
|
82
|
-
self.assertTrue(diff["has_changes"])
|
|
83
|
-
self.assertEqual(diff["untracked_count"], 1)
|
|
84
|
-
self.assertEqual(diff["total_additions"], 3)
|
|
85
|
-
|
|
86
|
-
# Commit initial file
|
|
87
|
-
subprocess.run(["git", "add", "."], cwd=self.test_dir, check=True)
|
|
88
|
-
subprocess.run(["git", "commit", "-m", "Init"], cwd=self.test_dir, check=True)
|
|
89
|
-
|
|
90
|
-
# Modify file
|
|
91
|
-
with open(f1, "w") as f:
|
|
92
|
-
f.write("Line 1 modified\nLine 2\nLine 3\nLine 4 added\n")
|
|
93
|
-
|
|
94
|
-
diff = get_diffstat_summary(cwd=self.test_dir)
|
|
95
|
-
self.assertTrue(diff["has_changes"])
|
|
96
|
-
self.assertEqual(diff["modified_count"], 1)
|
|
97
|
-
self.assertTrue(diff["total_additions"] > 0)
|
|
98
|
-
|
|
99
|
-
def test_reset_hard_analysis(self):
|
|
100
|
-
# Create 3 commits
|
|
101
|
-
f = os.path.join(self.test_dir, "file.txt")
|
|
102
|
-
for i in range(1, 4):
|
|
103
|
-
with open(f, "a") as fp:
|
|
104
|
-
fp.write(f"commit {i}\n")
|
|
105
|
-
subprocess.run(["git", "add", "."], cwd=self.test_dir, check=True)
|
|
106
|
-
subprocess.run(["git", "commit", "-m", f"commit {i}"], cwd=self.test_dir, check=True)
|
|
107
|
-
|
|
108
|
-
# Make uncommitted changes
|
|
109
|
-
with open(f, "a") as fp:
|
|
110
|
-
fp.write("uncommitted work\n")
|
|
111
|
-
|
|
112
|
-
report = DangerousOperationAnalyzer.analyze_reset_hard("HEAD~2", cwd=self.test_dir)
|
|
113
|
-
self.assertTrue(report.is_dangerous)
|
|
114
|
-
self.assertEqual(report.lost_items.get("commits"), 2)
|
|
115
|
-
self.assertEqual(report.lost_items.get("modified_files"), 1)
|
|
116
|
-
|
|
117
|
-
# Format danger box output
|
|
118
|
-
box = format_danger_box(report, repo_name="test-repo")
|
|
119
|
-
self.assertIn("git reset --hard HEAD~2", box)
|
|
120
|
-
self.assertIn("2 commit(s)", box)
|
|
121
|
-
self.assertIn("1 modified/staged file(s)", box)
|
|
122
|
-
self.assertIn("test-repo", box)
|
|
123
|
-
|
|
124
|
-
def test_force_push_analysis(self):
|
|
125
|
-
report = DangerousOperationAnalyzer.analyze_force_push("origin", "main", cwd=self.test_dir)
|
|
126
|
-
self.assertTrue(report.is_dangerous)
|
|
127
|
-
self.assertIn("PROTECTED BRANCH", report.risks[0])
|
|
128
|
-
|
|
129
|
-
def test_clean_analysis(self):
|
|
130
|
-
# Create untracked file
|
|
131
|
-
untracked = os.path.join(self.test_dir, "untracked.txt")
|
|
132
|
-
with open(untracked, "w") as fp:
|
|
133
|
-
fp.write("untracked\n")
|
|
134
|
-
|
|
135
|
-
report = DangerousOperationAnalyzer.analyze_clean(force=True, remove_dirs=True, cwd=self.test_dir)
|
|
136
|
-
self.assertTrue(report.is_dangerous)
|
|
137
|
-
self.assertEqual(report.lost_items.get("untracked_files"), 1)
|
|
138
|
-
|
|
139
|
-
def test_branch_delete_analysis(self):
|
|
140
|
-
# Create a branch with an unmerged commit
|
|
141
|
-
subprocess.run(["git", "checkout", "-b", "feature-x"], cwd=self.test_dir, check=True)
|
|
142
|
-
f = os.path.join(self.test_dir, "feat.txt")
|
|
143
|
-
with open(f, "w") as fp:
|
|
144
|
-
fp.write("feature\n")
|
|
145
|
-
subprocess.run(["git", "add", "."], cwd=self.test_dir, check=True)
|
|
146
|
-
subprocess.run(["git", "commit", "-m", "feature commit"], cwd=self.test_dir, check=True)
|
|
147
|
-
subprocess.run(["git", "checkout", "main"], cwd=self.test_dir, check=True)
|
|
148
|
-
|
|
149
|
-
report = DangerousOperationAnalyzer.analyze_branch_delete("feature-x", force=True, cwd=self.test_dir)
|
|
150
|
-
self.assertTrue(report.is_dangerous)
|
|
151
|
-
self.assertEqual(report.lost_items.get("unmerged_commits"), 1)
|
|
152
|
-
|
|
153
|
-
def test_arbitrary_command_analyzer(self):
|
|
154
|
-
# Test command parsing
|
|
155
|
-
rep1 = DangerousOperationAnalyzer.analyze_arbitrary_command(["git", "reset", "--hard", "HEAD~3"], cwd=self.test_dir)
|
|
156
|
-
self.assertIsNotNone(rep1)
|
|
157
|
-
self.assertIn("reset --hard", rep1.operation_name)
|
|
158
|
-
|
|
159
|
-
rep2 = DangerousOperationAnalyzer.analyze_arbitrary_command(["git", "clean", "-fd"], cwd=self.test_dir)
|
|
160
|
-
self.assertIsNotNone(rep2)
|
|
161
|
-
self.assertIn("Clean", rep2.operation_name)
|
|
162
|
-
|
|
163
|
-
rep3 = DangerousOperationAnalyzer.analyze_arbitrary_command(["git", "push", "--force", "origin", "main"], cwd=self.test_dir)
|
|
164
|
-
self.assertIsNotNone(rep3)
|
|
165
|
-
self.assertIn("Force Push", rep3.operation_name)
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
if __name__ == "__main__":
|
|
169
|
-
unittest.main()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|