gitpush-tool 0.4.2__tar.gz → 0.4.3__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.2 → gitpush_tool-0.4.3}/LONG_DESCRIPTION.md +2 -0
- {gitpush_tool-0.4.2/gitpush_tool.egg-info → gitpush_tool-0.4.3}/PKG-INFO +3 -1
- {gitpush_tool-0.4.2 → gitpush_tool-0.4.3}/README.md +52 -0
- gitpush_tool-0.4.3/gitpush/__init__.py +1 -0
- {gitpush_tool-0.4.2 → gitpush_tool-0.4.3}/gitpush/cli.py +28 -0
- gitpush_tool-0.4.3/gitpush/sync.py +524 -0
- {gitpush_tool-0.4.2 → gitpush_tool-0.4.3/gitpush_tool.egg-info}/PKG-INFO +3 -1
- {gitpush_tool-0.4.2 → gitpush_tool-0.4.3}/gitpush_tool.egg-info/SOURCES.txt +2 -0
- {gitpush_tool-0.4.2 → gitpush_tool-0.4.3}/gitpush_tool.egg-info/entry_points.txt +1 -0
- {gitpush_tool-0.4.2 → gitpush_tool-0.4.3}/setup.py +3 -2
- gitpush_tool-0.4.3/tests/test_sync.py +153 -0
- gitpush_tool-0.4.2/gitpush/__init__.py +0 -1
- {gitpush_tool-0.4.2 → gitpush_tool-0.4.3}/LICENSE +0 -0
- {gitpush_tool-0.4.2 → gitpush_tool-0.4.3}/MANIFEST.in +0 -0
- {gitpush_tool-0.4.2 → gitpush_tool-0.4.3}/gitpush/__doc__.py +0 -0
- {gitpush_tool-0.4.2 → gitpush_tool-0.4.3}/gitpush/safety.py +0 -0
- {gitpush_tool-0.4.2 → gitpush_tool-0.4.3}/gitpush/status.py +0 -0
- {gitpush_tool-0.4.2 → gitpush_tool-0.4.3}/gitpush/undo.py +0 -0
- {gitpush_tool-0.4.2 → gitpush_tool-0.4.3}/gitpush_tool.egg-info/dependency_links.txt +0 -0
- {gitpush_tool-0.4.2 → gitpush_tool-0.4.3}/gitpush_tool.egg-info/top_level.txt +0 -0
- {gitpush_tool-0.4.2 → gitpush_tool-0.4.3}/pyproject.toml +0 -0
- {gitpush_tool-0.4.2 → gitpush_tool-0.4.3}/setup.cfg +0 -0
- {gitpush_tool-0.4.2 → gitpush_tool-0.4.3}/tests/test_cli.py +0 -0
- {gitpush_tool-0.4.2 → gitpush_tool-0.4.3}/tests/test_safety.py +0 -0
- {gitpush_tool-0.4.2 → gitpush_tool-0.4.3}/tests/test_status.py +0 -0
- {gitpush_tool-0.4.2 → gitpush_tool-0.4.3}/tests/test_undo.py +0 -0
|
@@ -9,7 +9,9 @@ A supercharged Git CLI and safety tool that simplifies repository creation and p
|
|
|
9
9
|
|
|
10
10
|
## ✨ Features
|
|
11
11
|
|
|
12
|
+
- **Smart Sync Engine (`gitpush sync`):** Intelligent single-command replacement for `git pull → git add → git commit → git push`. Automatically detects repository state (clean, dirty, ahead, behind, diverged) and safely synchronizes with remote.
|
|
12
13
|
- **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.
|
|
14
|
+
- **Interactive Undo Engine (`gitpush undo`):** Reversible and safe rollback for commits and pushes with detailed previews.
|
|
13
15
|
- **Pre-Push Safety & Diffstat Preview:** See changed files, line additions/deletions (`+X, -Y`), ahead/behind status, and pending commits before pushing.
|
|
14
16
|
- **Dangerous Git Operation Guard:** Analyzes and protects against risky actions (`reset --hard`, force pushes, deleting unmerged branches, dirty `git clean`, detached HEADs).
|
|
15
17
|
- **Protected Branch Safeguard:** Automatically detects and alerts when pushing or force-pushing to `main`, `master`, `prod`, `release`, or `dev`.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: gitpush-tool
|
|
3
|
-
Version: 0.4.
|
|
3
|
+
Version: 0.4.3
|
|
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,7 +38,9 @@ A supercharged Git CLI and safety tool that simplifies repository creation and p
|
|
|
38
38
|
|
|
39
39
|
## ✨ Features
|
|
40
40
|
|
|
41
|
+
- **Smart Sync Engine (`gitpush sync`):** Intelligent single-command replacement for `git pull → git add → git commit → git push`. Automatically detects repository state (clean, dirty, ahead, behind, diverged) and safely synchronizes with remote.
|
|
41
42
|
- **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.
|
|
43
|
+
- **Interactive Undo Engine (`gitpush undo`):** Reversible and safe rollback for commits and pushes with detailed previews.
|
|
42
44
|
- **Pre-Push Safety & Diffstat Preview:** See changed files, line additions/deletions (`+X, -Y`), ahead/behind status, and pending commits before pushing.
|
|
43
45
|
- **Dangerous Git Operation Guard:** Analyzes and protects against risky actions (`reset --hard`, force pushes, deleting unmerged branches, dirty `git clean`, detached HEADs).
|
|
44
46
|
- **Protected Branch Safeguard:** Automatically detects and alerts when pushing or force-pushing to `main`, `master`, `prod`, `release`, or `dev`.
|
|
@@ -9,7 +9,9 @@ A supercharged Git CLI and safety tool that simplifies repository creation and p
|
|
|
9
9
|
|
|
10
10
|
## ✨ Features
|
|
11
11
|
|
|
12
|
+
- **Smart Sync Engine (`gitpush sync`):** Intelligent single-command replacement for `git pull → git add → git commit → git push`. Automatically detects repository state (clean, dirty, ahead, behind, diverged) and safely synchronizes with remote.
|
|
12
13
|
- **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.
|
|
14
|
+
- **Interactive Undo Engine (`gitpush undo`):** Reversible and safe rollback for commits and pushes with detailed previews.
|
|
13
15
|
- **Pre-Push Safety & Diffstat Preview:** See changed files, line additions/deletions (`+X, -Y`), ahead/behind status, and pending commits before pushing.
|
|
14
16
|
- **Dangerous Git Operation Guard:** Analyzes and protects against risky actions (`reset --hard`, force pushes, deleting unmerged branches, dirty `git clean`, detached HEADs).
|
|
15
17
|
- **Protected Branch Safeguard:** Automatically detects and alerts when pushing or force-pushing to `main`, `master`, `prod`, `release`, or `dev`.
|
|
@@ -20,6 +22,50 @@ A supercharged Git CLI and safety tool that simplifies repository creation and p
|
|
|
20
22
|
|
|
21
23
|
---
|
|
22
24
|
|
|
25
|
+
## 🔄 Smart Sync Engine (`gitpush sync`)
|
|
26
|
+
|
|
27
|
+
Instead of running four separate commands every time:
|
|
28
|
+
```bash
|
|
29
|
+
git pull
|
|
30
|
+
git add .
|
|
31
|
+
git commit -m "..."
|
|
32
|
+
git push
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Run **`gitpush sync`** (or `gitpush-sync`). GitPush detects your exact state and handles everything intelligently:
|
|
36
|
+
|
|
37
|
+
### Scenario A: Local Ahead
|
|
38
|
+
```text
|
|
39
|
+
Local: 3 commits ahead
|
|
40
|
+
Remote: 0 commits ahead
|
|
41
|
+
|
|
42
|
+
→ Safe to push
|
|
43
|
+
```
|
|
44
|
+
Prompts for push confirmation, or auto-pushes with `gitpush sync -y`.
|
|
45
|
+
|
|
46
|
+
### Scenario B: Branch Diverged
|
|
47
|
+
```text
|
|
48
|
+
Local: 2 commits ahead
|
|
49
|
+
Remote: 3 commits ahead
|
|
50
|
+
|
|
51
|
+
⚠ Branch has diverged.
|
|
52
|
+
|
|
53
|
+
Options:
|
|
54
|
+
1. Rebase — replay your commits on top of remote (clean history)
|
|
55
|
+
2. Merge — merge remote into local (preserves history)
|
|
56
|
+
3. Abort — cancel sync
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
### Scenario C: Modified Files Detected
|
|
60
|
+
```text
|
|
61
|
+
Working Tree: 3 file(s) modified (+45, -8)
|
|
62
|
+
|
|
63
|
+
Commit before sync? [y/N]: y
|
|
64
|
+
Commit message (Enter for auto): Add user authentication
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
---
|
|
68
|
+
|
|
23
69
|
## 📦 Installation
|
|
24
70
|
|
|
25
71
|
```bash
|
|
@@ -145,6 +191,12 @@ Continue? [y/N]:
|
|
|
145
191
|
|
|
146
192
|
| Command | Description |
|
|
147
193
|
|---|---|
|
|
194
|
+
| `gitpush sync` | Intelligent one-command sync (pull, commit dirty files, rebase/merge, push). |
|
|
195
|
+
| `gitpush sync -y` | Auto-confirms prompts and syncs immediately. |
|
|
196
|
+
| `gitpush sync -m "Msg"` | Sets custom commit message for modified files during sync. |
|
|
197
|
+
| `gitpush sync --rebase` | Prefers rebase automatically when branch has diverged. |
|
|
198
|
+
| `gitpush undo` | Interactive undo engine for commits and pushed history. |
|
|
199
|
+
| `gitpush status` | Decision-support status dashboard with recommendations. |
|
|
148
200
|
| `gitpush "Commit message"` | Stages all changes, previews diffs, commits, and pushes. |
|
|
149
201
|
| `gitpush` | Pushes staged/unpushed changes with safety preview. |
|
|
150
202
|
| `gitpush -y` | Pushes immediately without confirmation prompt. |
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.4.3"
|
|
@@ -47,6 +47,7 @@ from .safety import (
|
|
|
47
47
|
|
|
48
48
|
from .status import format_status_dashboard
|
|
49
49
|
from .undo import run_undo_flow
|
|
50
|
+
from .sync import run_sync_flow
|
|
50
51
|
|
|
51
52
|
|
|
52
53
|
# --- GitHub CLI Installation Orchestrator and Helpers ---
|
|
@@ -704,6 +705,29 @@ def run():
|
|
|
704
705
|
clean_args = [a for a in guard_args if a not in ("-y", "--yes")]
|
|
705
706
|
sys.exit(handle_guard_command(clean_args, auto_confirm=auto_confirm))
|
|
706
707
|
|
|
708
|
+
# Handle sync subcommand upfront if detected
|
|
709
|
+
if len(sys.argv) > 1 and sys.argv[1].lower() == "sync":
|
|
710
|
+
sync_args = sys.argv[2:]
|
|
711
|
+
sync_auto = "-y" in sync_args or "--yes" in sync_args
|
|
712
|
+
sync_rebase = "--rebase" in sync_args
|
|
713
|
+
sync_message = None
|
|
714
|
+
# Parse -m / --message
|
|
715
|
+
for i, arg in enumerate(sync_args):
|
|
716
|
+
if arg in ("-m", "--message") and i + 1 < len(sync_args):
|
|
717
|
+
sync_message = sync_args[i + 1]
|
|
718
|
+
break
|
|
719
|
+
sync_remote = "origin"
|
|
720
|
+
for i, arg in enumerate(sync_args):
|
|
721
|
+
if arg in ("-r", "--remote") and i + 1 < len(sync_args):
|
|
722
|
+
sync_remote = sync_args[i + 1]
|
|
723
|
+
break
|
|
724
|
+
sys.exit(run_sync_flow(
|
|
725
|
+
auto_confirm=sync_auto,
|
|
726
|
+
commit_message=sync_message,
|
|
727
|
+
prefer_rebase=sync_rebase,
|
|
728
|
+
remote=sync_remote,
|
|
729
|
+
))
|
|
730
|
+
|
|
707
731
|
parser = argparse.ArgumentParser(
|
|
708
732
|
description="🚀 Supercharged Git push tool with decision-support status, undo engine, and safety guards",
|
|
709
733
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
@@ -712,6 +736,10 @@ def run():
|
|
|
712
736
|
Undo last commit: gitpush undo commit
|
|
713
737
|
Undo push / rollback: gitpush undo push
|
|
714
738
|
Decision status: gitpush status
|
|
739
|
+
Smart sync: gitpush sync
|
|
740
|
+
Sync (auto-confirm): gitpush sync -y
|
|
741
|
+
Sync with message: gitpush sync -m "My changes"
|
|
742
|
+
Sync (prefer rebase): gitpush sync --rebase
|
|
715
743
|
Standard push: gitpush "My new feature"
|
|
716
744
|
Push without prompt: gitpush "My new feature" -y
|
|
717
745
|
Dry run preview: gitpush "My new feature" --dry-run
|
|
@@ -0,0 +1,524 @@
|
|
|
1
|
+
"""
|
|
2
|
+
GitPush Sync Engine
|
|
3
|
+
Intelligent single-command replacement for the pull → add → commit → push cycle.
|
|
4
|
+
Detects repository state and acts accordingly with interactive prompts.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import sys
|
|
8
|
+
import subprocess
|
|
9
|
+
from typing import Optional, List
|
|
10
|
+
|
|
11
|
+
from .safety import (
|
|
12
|
+
is_git_repo,
|
|
13
|
+
get_repo_name,
|
|
14
|
+
get_repo_root,
|
|
15
|
+
get_current_branch,
|
|
16
|
+
is_detached_head,
|
|
17
|
+
is_protected_branch,
|
|
18
|
+
get_ahead_behind,
|
|
19
|
+
get_diffstat_summary,
|
|
20
|
+
get_pending_commits,
|
|
21
|
+
run_git,
|
|
22
|
+
colorize,
|
|
23
|
+
Style,
|
|
24
|
+
prompt_confirm,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
# ──────────────────────────────────────────────────────────────────
|
|
29
|
+
# Helpers
|
|
30
|
+
# ──────────────────────────────────────────────────────────────────
|
|
31
|
+
|
|
32
|
+
def _fetch(remote: str = "origin", cwd: Optional[str] = None) -> bool:
|
|
33
|
+
"""Fetch from remote silently. Returns True on success."""
|
|
34
|
+
print(f"\n{colorize('🔄 Fetching from remote...', Style.DIM)}")
|
|
35
|
+
res = run_git(["fetch", remote], check=False, cwd=cwd)
|
|
36
|
+
if res.returncode != 0:
|
|
37
|
+
err = res.stderr.strip() if res.stderr else "unknown error"
|
|
38
|
+
print(colorize(f" ⚠️ Fetch failed: {err}", Style.YELLOW))
|
|
39
|
+
return False
|
|
40
|
+
return True
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _get_tracking_info(branch: str, cwd: Optional[str] = None) -> Optional[str]:
|
|
44
|
+
"""Return the upstream tracking ref (e.g. 'origin/main') or None."""
|
|
45
|
+
res = run_git(["rev-parse", "--abbrev-ref", "--symbolic-full-name", f"{branch}@{{u}}"], cwd=cwd)
|
|
46
|
+
if res.returncode == 0 and res.stdout.strip():
|
|
47
|
+
return res.stdout.strip()
|
|
48
|
+
return None
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _has_remote(remote: str = "origin", cwd: Optional[str] = None) -> bool:
|
|
52
|
+
"""Check if the named remote exists."""
|
|
53
|
+
res = run_git(["remote", "get-url", remote], cwd=cwd)
|
|
54
|
+
return res.returncode == 0
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _set_upstream_and_push(remote: str, branch: str, cwd: Optional[str] = None) -> bool:
|
|
58
|
+
"""Push current branch and set upstream tracking."""
|
|
59
|
+
print(f"🚀 Pushing and setting upstream → {remote}/{branch}")
|
|
60
|
+
try:
|
|
61
|
+
subprocess.run(
|
|
62
|
+
["git", "push", "-u", remote, branch],
|
|
63
|
+
check=True,
|
|
64
|
+
cwd=cwd
|
|
65
|
+
)
|
|
66
|
+
print(colorize("✅ Upstream set and pushed successfully.", Style.GREEN))
|
|
67
|
+
return True
|
|
68
|
+
except subprocess.CalledProcessError as e:
|
|
69
|
+
err = e.stderr.decode(errors="ignore").strip() if e.stderr else str(e)
|
|
70
|
+
print(f"❌ Push failed: {err}", file=sys.stderr)
|
|
71
|
+
return False
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _auto_generate_message(diff_summary: dict) -> str:
|
|
75
|
+
"""Generate a short commit message from the diff summary."""
|
|
76
|
+
total = diff_summary["modified_count"]
|
|
77
|
+
adds = diff_summary["total_additions"]
|
|
78
|
+
dels = diff_summary["total_deletions"]
|
|
79
|
+
parts = []
|
|
80
|
+
if total == 1:
|
|
81
|
+
parts.append(f"Update {diff_summary['files'][0].path}")
|
|
82
|
+
else:
|
|
83
|
+
parts.append(f"Update {total} files")
|
|
84
|
+
parts.append(f"(+{adds}, -{dels})")
|
|
85
|
+
return " ".join(parts)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _stage_and_commit(message: str, cwd: Optional[str] = None) -> bool:
|
|
89
|
+
"""Stage all changes and commit."""
|
|
90
|
+
try:
|
|
91
|
+
subprocess.run(["git", "add", "."], check=True, capture_output=True, cwd=cwd)
|
|
92
|
+
subprocess.run(
|
|
93
|
+
["git", "commit", "-m", message, "--allow-empty-message"],
|
|
94
|
+
check=True,
|
|
95
|
+
capture_output=True,
|
|
96
|
+
cwd=cwd
|
|
97
|
+
)
|
|
98
|
+
return True
|
|
99
|
+
except subprocess.CalledProcessError as e:
|
|
100
|
+
err = e.stderr.decode(errors="ignore").strip() if e.stderr else str(e)
|
|
101
|
+
if "nothing to commit" in err:
|
|
102
|
+
print(colorize("ℹ️ Nothing to commit.", Style.DIM))
|
|
103
|
+
return True
|
|
104
|
+
print(f"❌ Commit failed: {err}", file=sys.stderr)
|
|
105
|
+
return False
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _pull_ff(remote: str, branch: str, cwd: Optional[str] = None) -> bool:
|
|
109
|
+
"""Fast-forward pull."""
|
|
110
|
+
print(f"⬇️ Pulling {remote}/{branch} (fast-forward)...")
|
|
111
|
+
try:
|
|
112
|
+
subprocess.run(
|
|
113
|
+
["git", "pull", "--ff-only", remote, branch],
|
|
114
|
+
check=True,
|
|
115
|
+
cwd=cwd
|
|
116
|
+
)
|
|
117
|
+
print(colorize("✅ Fast-forward pull complete.", Style.GREEN))
|
|
118
|
+
return True
|
|
119
|
+
except subprocess.CalledProcessError:
|
|
120
|
+
print(colorize("⚠️ Fast-forward failed (branches may have diverged).", Style.YELLOW))
|
|
121
|
+
return False
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _pull_merge(remote: str, branch: str, cwd: Optional[str] = None) -> bool:
|
|
125
|
+
"""Pull with merge strategy."""
|
|
126
|
+
print(f"⬇️ Pulling {remote}/{branch} (merge)...")
|
|
127
|
+
try:
|
|
128
|
+
result = subprocess.run(
|
|
129
|
+
["git", "pull", remote, branch],
|
|
130
|
+
capture_output=True, text=True,
|
|
131
|
+
cwd=cwd
|
|
132
|
+
)
|
|
133
|
+
if result.returncode != 0:
|
|
134
|
+
err = result.stderr.strip() if result.stderr else result.stdout.strip()
|
|
135
|
+
if "CONFLICT" in err or "CONFLICT" in (result.stdout or ""):
|
|
136
|
+
print(colorize("❗ Merge conflicts detected. Resolve them and run `gitpush sync` again.", Style.RED))
|
|
137
|
+
_show_conflicts(cwd=cwd)
|
|
138
|
+
return False
|
|
139
|
+
print(f"❌ Merge pull failed: {err}", file=sys.stderr)
|
|
140
|
+
return False
|
|
141
|
+
|
|
142
|
+
if "CONFLICT" in (result.stdout or ""):
|
|
143
|
+
print(colorize("❗ Merge conflicts detected. Resolve them and run `gitpush sync` again.", Style.RED))
|
|
144
|
+
_show_conflicts(cwd=cwd)
|
|
145
|
+
return False
|
|
146
|
+
|
|
147
|
+
print(colorize("✅ Merge pull complete.", Style.GREEN))
|
|
148
|
+
return True
|
|
149
|
+
except subprocess.CalledProcessError as e:
|
|
150
|
+
err = e.stderr.decode(errors="ignore").strip() if e.stderr else str(e)
|
|
151
|
+
print(f"❌ Pull failed: {err}", file=sys.stderr)
|
|
152
|
+
return False
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _pull_rebase(remote: str, branch: str, cwd: Optional[str] = None) -> bool:
|
|
156
|
+
"""Pull with rebase strategy."""
|
|
157
|
+
print(f"🔁 Rebasing onto {remote}/{branch}...")
|
|
158
|
+
try:
|
|
159
|
+
result = subprocess.run(
|
|
160
|
+
["git", "pull", "--rebase", remote, branch],
|
|
161
|
+
capture_output=True, text=True,
|
|
162
|
+
cwd=cwd
|
|
163
|
+
)
|
|
164
|
+
if result.returncode != 0:
|
|
165
|
+
err = result.stderr.strip() if result.stderr else result.stdout.strip()
|
|
166
|
+
if "CONFLICT" in err or "CONFLICT" in (result.stdout or ""):
|
|
167
|
+
print(colorize("❗ Rebase conflicts detected.", Style.RED))
|
|
168
|
+
print(" Resolve conflicts, then run:")
|
|
169
|
+
print(colorize(" git rebase --continue", Style.CYAN))
|
|
170
|
+
print(" Or abort with:")
|
|
171
|
+
print(colorize(" git rebase --abort", Style.CYAN))
|
|
172
|
+
return False
|
|
173
|
+
print(f"❌ Rebase failed: {err}", file=sys.stderr)
|
|
174
|
+
return False
|
|
175
|
+
print(colorize("✅ Rebase complete.", Style.GREEN))
|
|
176
|
+
return True
|
|
177
|
+
except subprocess.CalledProcessError as e:
|
|
178
|
+
err = e.stderr.decode(errors="ignore").strip() if e.stderr else str(e)
|
|
179
|
+
print(f"❌ Rebase failed: {err}", file=sys.stderr)
|
|
180
|
+
return False
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _push(remote: str, branch: str, cwd: Optional[str] = None) -> bool:
|
|
184
|
+
"""Standard push."""
|
|
185
|
+
print(f"🚀 Pushing to {remote}/{branch}...")
|
|
186
|
+
try:
|
|
187
|
+
subprocess.run(["git", "push", remote, branch], check=True, cwd=cwd)
|
|
188
|
+
print(colorize("✅ Push complete.", Style.GREEN))
|
|
189
|
+
return True
|
|
190
|
+
except subprocess.CalledProcessError as e:
|
|
191
|
+
err = e.stderr.decode(errors="ignore").strip() if e.stderr else str(e)
|
|
192
|
+
print(f"❌ Push failed: {err}", file=sys.stderr)
|
|
193
|
+
return False
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _show_conflicts(cwd: Optional[str] = None):
|
|
197
|
+
"""Print conflicted files."""
|
|
198
|
+
res = run_git(["diff", "--name-only", "--diff-filter=U"], cwd=cwd)
|
|
199
|
+
if res.returncode == 0 and res.stdout.strip():
|
|
200
|
+
files = res.stdout.strip().splitlines()
|
|
201
|
+
print(colorize(f"\n Conflicted files ({len(files)}):", Style.BOLD))
|
|
202
|
+
for f in files[:10]:
|
|
203
|
+
print(f" • {f}")
|
|
204
|
+
if len(files) > 10:
|
|
205
|
+
print(f" • ... and {len(files) - 10} more")
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _prompt_choice(prompt_text: str, options: List[str]) -> int:
|
|
209
|
+
"""
|
|
210
|
+
Display numbered options and prompt user to choose.
|
|
211
|
+
Returns 0-based index, or -1 on cancel.
|
|
212
|
+
"""
|
|
213
|
+
print()
|
|
214
|
+
for i, opt in enumerate(options, 1):
|
|
215
|
+
print(f" {colorize(str(i), Style.BOLD, Style.CYAN)}. {opt}")
|
|
216
|
+
print()
|
|
217
|
+
try:
|
|
218
|
+
sys.stdout.write(colorize(prompt_text, Style.BOLD))
|
|
219
|
+
sys.stdout.flush()
|
|
220
|
+
choice = input().strip()
|
|
221
|
+
if choice.isdigit():
|
|
222
|
+
idx = int(choice) - 1
|
|
223
|
+
if 0 <= idx < len(options):
|
|
224
|
+
return idx
|
|
225
|
+
return -1
|
|
226
|
+
except (EOFError, KeyboardInterrupt):
|
|
227
|
+
sys.stdout.write("\n")
|
|
228
|
+
return -1
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
# ──────────────────────────────────────────────────────────────────
|
|
232
|
+
# Sync Dashboard
|
|
233
|
+
# ──────────────────────────────────────────────────────────────────
|
|
234
|
+
|
|
235
|
+
def _print_sync_header(branch: str, remote: str, behind: int, ahead: int, diff_summary: dict, cwd: Optional[str] = None):
|
|
236
|
+
"""Print a compact sync status dashboard."""
|
|
237
|
+
repo = get_repo_name(cwd)
|
|
238
|
+
|
|
239
|
+
print()
|
|
240
|
+
print(colorize("═══════════════════════════════════════════════════════════════", Style.CYAN))
|
|
241
|
+
print(colorize(" 🔄 GITPUSH SYNC", Style.BOLD, Style.CYAN))
|
|
242
|
+
print(colorize("═══════════════════════════════════════════════════════════════", Style.CYAN))
|
|
243
|
+
print()
|
|
244
|
+
|
|
245
|
+
print(f" {colorize('Repository:', Style.BOLD)} {colorize(repo, Style.GREEN)}")
|
|
246
|
+
|
|
247
|
+
branch_display = branch
|
|
248
|
+
if is_protected_branch(branch):
|
|
249
|
+
branch_display = colorize(f"{branch} ⚠️ [PROTECTED]", Style.BOLD, Style.YELLOW)
|
|
250
|
+
else:
|
|
251
|
+
branch_display = colorize(branch, Style.CYAN)
|
|
252
|
+
print(f" {colorize('Branch:', Style.BOLD)} {branch_display} ↔ {colorize(f'{remote}/{branch}', Style.DIM)}")
|
|
253
|
+
|
|
254
|
+
# Sync indicators
|
|
255
|
+
local_label = f"{ahead} commit(s) ahead" if ahead > 0 else "0 commits ahead"
|
|
256
|
+
remote_label = f"{behind} commit(s) ahead" if behind > 0 else "0 commits ahead"
|
|
257
|
+
|
|
258
|
+
if ahead > 0:
|
|
259
|
+
local_styled = colorize(local_label, Style.GREEN)
|
|
260
|
+
else:
|
|
261
|
+
local_styled = colorize(local_label, Style.DIM)
|
|
262
|
+
|
|
263
|
+
if behind > 0:
|
|
264
|
+
remote_styled = colorize(remote_label, Style.YELLOW)
|
|
265
|
+
else:
|
|
266
|
+
remote_styled = colorize(remote_label, Style.DIM)
|
|
267
|
+
|
|
268
|
+
print(f" {colorize('Local:', Style.BOLD)} {local_styled}")
|
|
269
|
+
print(f" {colorize('Remote:', Style.BOLD)} {remote_styled}")
|
|
270
|
+
|
|
271
|
+
# Working tree
|
|
272
|
+
if diff_summary["has_changes"]:
|
|
273
|
+
n_files = diff_summary["modified_count"]
|
|
274
|
+
adds = colorize(f"+{diff_summary['total_additions']}", Style.GREEN)
|
|
275
|
+
dels = colorize(f"-{diff_summary['total_deletions']}", Style.RED)
|
|
276
|
+
print(f" {colorize('Working Tree:', Style.BOLD)} {colorize(f'{n_files} file(s) modified', Style.YELLOW)} ({adds}, {dels})")
|
|
277
|
+
else:
|
|
278
|
+
print(f" {colorize('Working Tree:', Style.BOLD)} {colorize('Clean', Style.GREEN)}")
|
|
279
|
+
|
|
280
|
+
print()
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def _print_sync_footer(success: bool):
|
|
284
|
+
"""Print the sync result footer."""
|
|
285
|
+
if success:
|
|
286
|
+
print(colorize("═══════════════════════════════════════════════════════════════", Style.GREEN))
|
|
287
|
+
print(colorize(" ✅ Sync complete!", Style.BOLD, Style.GREEN))
|
|
288
|
+
print(colorize("═══════════════════════════════════════════════════════════════", Style.GREEN))
|
|
289
|
+
else:
|
|
290
|
+
print(colorize("═══════════════════════════════════════════════════════════════", Style.RED))
|
|
291
|
+
print(colorize(" ❌ Sync incomplete — see errors above.", Style.BOLD, Style.RED))
|
|
292
|
+
print(colorize("═══════════════════════════════════════════════════════════════", Style.RED))
|
|
293
|
+
print()
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
# ──────────────────────────────────────────────────────────────────
|
|
297
|
+
# Main Sync Flow
|
|
298
|
+
# ──────────────────────────────────────────────────────────────────
|
|
299
|
+
|
|
300
|
+
def run_sync_flow(
|
|
301
|
+
auto_confirm: bool = False,
|
|
302
|
+
commit_message: Optional[str] = None,
|
|
303
|
+
prefer_rebase: bool = False,
|
|
304
|
+
remote: str = "origin",
|
|
305
|
+
cwd: Optional[str] = None,
|
|
306
|
+
) -> int:
|
|
307
|
+
"""
|
|
308
|
+
Run the full sync flow:
|
|
309
|
+
1. Validate repo state
|
|
310
|
+
2. Fetch remote
|
|
311
|
+
3. Detect scenario (clean, dirty, ahead, behind, diverged, no upstream)
|
|
312
|
+
4. Execute appropriate actions with interactive prompts
|
|
313
|
+
5. Report result
|
|
314
|
+
|
|
315
|
+
Returns exit code: 0 for success, 1 for failure/abort.
|
|
316
|
+
"""
|
|
317
|
+
|
|
318
|
+
# ── 1. Validate ──────────────────────────────────────────────
|
|
319
|
+
if not is_git_repo(cwd):
|
|
320
|
+
print("❌ Not a Git repository (or any of the parent directories).", file=sys.stderr)
|
|
321
|
+
print("➡️ Run 'gitpush --init' or 'git init' first.", file=sys.stderr)
|
|
322
|
+
return 1
|
|
323
|
+
|
|
324
|
+
if is_detached_head(cwd):
|
|
325
|
+
print(colorize("❌ Cannot sync in detached HEAD state.", Style.RED))
|
|
326
|
+
print("➡️ Check out a branch first: git checkout <branch>")
|
|
327
|
+
return 1
|
|
328
|
+
|
|
329
|
+
branch = get_current_branch(cwd)
|
|
330
|
+
|
|
331
|
+
# Protected branch warning
|
|
332
|
+
if is_protected_branch(branch):
|
|
333
|
+
print(colorize(f"\n⚠️ You are syncing on protected branch '{branch}'.", Style.BOLD, Style.YELLOW))
|
|
334
|
+
if not auto_confirm:
|
|
335
|
+
if not prompt_confirm("Continue? [y/N]: "):
|
|
336
|
+
print(colorize("🛑 Sync cancelled.", Style.YELLOW))
|
|
337
|
+
return 0
|
|
338
|
+
|
|
339
|
+
# ── 2. Check remote & upstream ───────────────────────────────
|
|
340
|
+
if not _has_remote(remote, cwd=cwd):
|
|
341
|
+
print(colorize(f"❌ Remote '{remote}' does not exist.", Style.RED))
|
|
342
|
+
print(f"➡️ Add one with: git remote add {remote} <url>")
|
|
343
|
+
return 1
|
|
344
|
+
|
|
345
|
+
tracking = _get_tracking_info(branch, cwd=cwd)
|
|
346
|
+
no_upstream = tracking is None
|
|
347
|
+
|
|
348
|
+
# ── 3. Fetch ─────────────────────────────────────────────────
|
|
349
|
+
if not no_upstream:
|
|
350
|
+
_fetch(remote, cwd=cwd)
|
|
351
|
+
|
|
352
|
+
# ── 4. Detect state ──────────────────────────────────────────
|
|
353
|
+
diff_summary = get_diffstat_summary(cwd=cwd)
|
|
354
|
+
has_dirty = diff_summary["has_changes"]
|
|
355
|
+
|
|
356
|
+
if no_upstream:
|
|
357
|
+
behind, ahead = 0, 0
|
|
358
|
+
else:
|
|
359
|
+
behind, ahead = get_ahead_behind(remote, branch, cwd=cwd)
|
|
360
|
+
|
|
361
|
+
# Print dashboard
|
|
362
|
+
_print_sync_header(branch, remote, behind, ahead, diff_summary, cwd=cwd)
|
|
363
|
+
|
|
364
|
+
# ── 5. Handle no-upstream scenario ───────────────────────────
|
|
365
|
+
if no_upstream:
|
|
366
|
+
print(colorize("ℹ️ No upstream tracking branch configured.", Style.YELLOW))
|
|
367
|
+
|
|
368
|
+
# If dirty, commit first
|
|
369
|
+
if has_dirty:
|
|
370
|
+
print(colorize(" Modified files detected.", Style.YELLOW))
|
|
371
|
+
if not auto_confirm:
|
|
372
|
+
if not prompt_confirm(" Commit before pushing? [y/N]: "):
|
|
373
|
+
print(colorize("🛑 Sync cancelled.", Style.YELLOW))
|
|
374
|
+
return 0
|
|
375
|
+
msg = commit_message or _auto_generate_message(diff_summary)
|
|
376
|
+
print(f"\n📦 Committing: {colorize(repr(msg), Style.WHITE)}")
|
|
377
|
+
if not _stage_and_commit(msg, cwd=cwd):
|
|
378
|
+
_print_sync_footer(False)
|
|
379
|
+
return 1
|
|
380
|
+
|
|
381
|
+
if not auto_confirm:
|
|
382
|
+
if not prompt_confirm(f" Push and set upstream to {remote}/{branch}? [y/N]: "):
|
|
383
|
+
print(colorize("🛑 Sync cancelled.", Style.YELLOW))
|
|
384
|
+
return 0
|
|
385
|
+
|
|
386
|
+
success = _set_upstream_and_push(remote, branch, cwd=cwd)
|
|
387
|
+
_print_sync_footer(success)
|
|
388
|
+
return 0 if success else 1
|
|
389
|
+
|
|
390
|
+
# ── 6. Handle dirty working tree ─────────────────────────────
|
|
391
|
+
if has_dirty:
|
|
392
|
+
print(colorize("📝 Modified files detected.", Style.YELLOW))
|
|
393
|
+
n_files = diff_summary["modified_count"]
|
|
394
|
+
# Show first few changed files
|
|
395
|
+
for f in diff_summary["files"][:8]:
|
|
396
|
+
status_icon = {"M": "~", "A": "+", "D": "-", "??": "?"}.get(f.status, "·")
|
|
397
|
+
print(f" {colorize(status_icon, Style.CYAN)} {f.path}")
|
|
398
|
+
if n_files > 8:
|
|
399
|
+
print(colorize(f" ... and {n_files - 8} more", Style.DIM))
|
|
400
|
+
print()
|
|
401
|
+
|
|
402
|
+
if not auto_confirm:
|
|
403
|
+
if not prompt_confirm(" Commit before sync? [y/N]: "):
|
|
404
|
+
print(colorize("⚠️ Skipping commit. Only existing commits will be synced.", Style.YELLOW))
|
|
405
|
+
else:
|
|
406
|
+
msg = commit_message
|
|
407
|
+
if not msg:
|
|
408
|
+
try:
|
|
409
|
+
sys.stdout.write(colorize(" Commit message (Enter for auto): ", Style.BOLD))
|
|
410
|
+
sys.stdout.flush()
|
|
411
|
+
msg = input().strip()
|
|
412
|
+
except (EOFError, KeyboardInterrupt):
|
|
413
|
+
print()
|
|
414
|
+
msg = ""
|
|
415
|
+
if not msg:
|
|
416
|
+
msg = _auto_generate_message(diff_summary)
|
|
417
|
+
print(f"\n📦 Committing: {colorize(repr(msg), Style.WHITE)}")
|
|
418
|
+
if not _stage_and_commit(msg, cwd=cwd):
|
|
419
|
+
_print_sync_footer(False)
|
|
420
|
+
return 1
|
|
421
|
+
|
|
422
|
+
# Refresh ahead count after commit
|
|
423
|
+
_, ahead = get_ahead_behind(remote, branch, cwd=cwd)
|
|
424
|
+
else:
|
|
425
|
+
# Auto mode: commit with provided or auto message
|
|
426
|
+
msg = commit_message or _auto_generate_message(diff_summary)
|
|
427
|
+
print(f"\n📦 Committing: {colorize(repr(msg), Style.WHITE)}")
|
|
428
|
+
if not _stage_and_commit(msg, cwd=cwd):
|
|
429
|
+
_print_sync_footer(False)
|
|
430
|
+
return 1
|
|
431
|
+
_, ahead = get_ahead_behind(remote, branch, cwd=cwd)
|
|
432
|
+
|
|
433
|
+
# Re-check counts after potential commit
|
|
434
|
+
behind, ahead = get_ahead_behind(remote, branch, cwd=cwd)
|
|
435
|
+
|
|
436
|
+
# ── 7. Scenario: Synced ──────────────────────────────────────
|
|
437
|
+
if behind == 0 and ahead == 0:
|
|
438
|
+
print(colorize("✅ Already in sync — nothing to do.", Style.GREEN))
|
|
439
|
+
_print_sync_footer(True)
|
|
440
|
+
return 0
|
|
441
|
+
|
|
442
|
+
# ── 8. Scenario: Local ahead only → push ─────────────────────
|
|
443
|
+
if ahead > 0 and behind == 0:
|
|
444
|
+
print(colorize(f"→ Safe to push ({ahead} commit(s) ahead, remote is up to date).", Style.GREEN))
|
|
445
|
+
print()
|
|
446
|
+
|
|
447
|
+
# Show pending commits
|
|
448
|
+
pending = get_pending_commits(remote, branch, cwd=cwd)
|
|
449
|
+
if pending:
|
|
450
|
+
print(colorize(f" Commits to push ({len(pending)}):", Style.BOLD))
|
|
451
|
+
for sha, subj in pending[:5]:
|
|
452
|
+
print(f" • {colorize(sha, Style.YELLOW)} {subj}")
|
|
453
|
+
if len(pending) > 5:
|
|
454
|
+
print(colorize(f" • ... and {len(pending) - 5} more", Style.DIM))
|
|
455
|
+
print()
|
|
456
|
+
|
|
457
|
+
if not auto_confirm:
|
|
458
|
+
if not prompt_confirm(" Push now? [Y/n]: "):
|
|
459
|
+
print(colorize("🛑 Push cancelled.", Style.YELLOW))
|
|
460
|
+
return 0
|
|
461
|
+
|
|
462
|
+
success = _push(remote, branch, cwd=cwd)
|
|
463
|
+
_print_sync_footer(success)
|
|
464
|
+
return 0 if success else 1
|
|
465
|
+
|
|
466
|
+
# ── 9. Scenario: Remote ahead only → pull ────────────────────
|
|
467
|
+
if behind > 0 and ahead == 0:
|
|
468
|
+
print(colorize(f"→ Remote is {behind} commit(s) ahead. Pulling...", Style.YELLOW))
|
|
469
|
+
print()
|
|
470
|
+
|
|
471
|
+
success = _pull_ff(remote, branch, cwd=cwd)
|
|
472
|
+
if not success:
|
|
473
|
+
# Fast-forward failed, try regular pull
|
|
474
|
+
success = _pull_merge(remote, branch, cwd=cwd)
|
|
475
|
+
|
|
476
|
+
_print_sync_footer(success)
|
|
477
|
+
return 0 if success else 1
|
|
478
|
+
|
|
479
|
+
# ── 10. Scenario: Diverged ───────────────────────────────────
|
|
480
|
+
if behind > 0 and ahead > 0:
|
|
481
|
+
print(colorize(f"⚠️ Branch has diverged.", Style.BOLD, Style.YELLOW))
|
|
482
|
+
print(f" Local: {colorize(f'{ahead} commit(s) ahead', Style.GREEN)}")
|
|
483
|
+
print(f" Remote: {colorize(f'{behind} commit(s) ahead', Style.YELLOW)}")
|
|
484
|
+
print()
|
|
485
|
+
|
|
486
|
+
if prefer_rebase or auto_confirm:
|
|
487
|
+
# Auto mode or --rebase flag → rebase
|
|
488
|
+
strategy = "rebase"
|
|
489
|
+
else:
|
|
490
|
+
choice = _prompt_choice(
|
|
491
|
+
"Choose strategy [1/2/3]: ",
|
|
492
|
+
[
|
|
493
|
+
colorize("Rebase", Style.GREEN) + " — replay your commits on top of remote (clean history)",
|
|
494
|
+
colorize("Merge", Style.CYAN) + " — merge remote into local (preserves history)",
|
|
495
|
+
colorize("Abort", Style.RED) + " — cancel sync",
|
|
496
|
+
]
|
|
497
|
+
)
|
|
498
|
+
if choice == 0:
|
|
499
|
+
strategy = "rebase"
|
|
500
|
+
elif choice == 1:
|
|
501
|
+
strategy = "merge"
|
|
502
|
+
else:
|
|
503
|
+
print(colorize("\n🛑 Sync aborted.", Style.YELLOW))
|
|
504
|
+
return 0
|
|
505
|
+
|
|
506
|
+
if strategy == "rebase":
|
|
507
|
+
success = _pull_rebase(remote, branch, cwd=cwd)
|
|
508
|
+
else:
|
|
509
|
+
success = _pull_merge(remote, branch, cwd=cwd)
|
|
510
|
+
|
|
511
|
+
if not success:
|
|
512
|
+
_print_sync_footer(False)
|
|
513
|
+
return 1
|
|
514
|
+
|
|
515
|
+
# After successful rebase/merge, push
|
|
516
|
+
print()
|
|
517
|
+
success = _push(remote, branch, cwd=cwd)
|
|
518
|
+
_print_sync_footer(success)
|
|
519
|
+
return 0 if success else 1
|
|
520
|
+
|
|
521
|
+
# ── Fallback (shouldn't reach here) ──────────────────────────
|
|
522
|
+
print(colorize("ℹ️ Could not determine sync state.", Style.DIM))
|
|
523
|
+
_print_sync_footer(False)
|
|
524
|
+
return 1
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: gitpush-tool
|
|
3
|
-
Version: 0.4.
|
|
3
|
+
Version: 0.4.3
|
|
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,7 +38,9 @@ A supercharged Git CLI and safety tool that simplifies repository creation and p
|
|
|
38
38
|
|
|
39
39
|
## ✨ Features
|
|
40
40
|
|
|
41
|
+
- **Smart Sync Engine (`gitpush sync`):** Intelligent single-command replacement for `git pull → git add → git commit → git push`. Automatically detects repository state (clean, dirty, ahead, behind, diverged) and safely synchronizes with remote.
|
|
41
42
|
- **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.
|
|
43
|
+
- **Interactive Undo Engine (`gitpush undo`):** Reversible and safe rollback for commits and pushes with detailed previews.
|
|
42
44
|
- **Pre-Push Safety & Diffstat Preview:** See changed files, line additions/deletions (`+X, -Y`), ahead/behind status, and pending commits before pushing.
|
|
43
45
|
- **Dangerous Git Operation Guard:** Analyzes and protects against risky actions (`reset --hard`, force pushes, deleting unmerged branches, dirty `git clean`, detached HEADs).
|
|
44
46
|
- **Protected Branch Safeguard:** Automatically detects and alerts when pushing or force-pushing to `main`, `master`, `prod`, `release`, or `dev`.
|
|
@@ -9,6 +9,7 @@ gitpush/__init__.py
|
|
|
9
9
|
gitpush/cli.py
|
|
10
10
|
gitpush/safety.py
|
|
11
11
|
gitpush/status.py
|
|
12
|
+
gitpush/sync.py
|
|
12
13
|
gitpush/undo.py
|
|
13
14
|
gitpush_tool.egg-info/PKG-INFO
|
|
14
15
|
gitpush_tool.egg-info/SOURCES.txt
|
|
@@ -18,4 +19,5 @@ gitpush_tool.egg-info/top_level.txt
|
|
|
18
19
|
tests/test_cli.py
|
|
19
20
|
tests/test_safety.py
|
|
20
21
|
tests/test_status.py
|
|
22
|
+
tests/test_sync.py
|
|
21
23
|
tests/test_undo.py
|
|
@@ -6,7 +6,7 @@ long_description = (Path(__file__).parent / "LONG_DESCRIPTION.md").read_text(enc
|
|
|
6
6
|
|
|
7
7
|
setup(
|
|
8
8
|
name="gitpush-tool",
|
|
9
|
-
version="0.4.
|
|
9
|
+
version="0.4.3",
|
|
10
10
|
packages=find_packages(),
|
|
11
11
|
install_requires=[],
|
|
12
12
|
entry_points={
|
|
@@ -14,7 +14,8 @@ setup(
|
|
|
14
14
|
"gitpush=gitpush.cli:run",
|
|
15
15
|
"gitpush-guard=gitpush.cli:run",
|
|
16
16
|
"gitpush-status=gitpush.cli:run",
|
|
17
|
-
"gitpush-undo=gitpush.cli:run"
|
|
17
|
+
"gitpush-undo=gitpush.cli:run",
|
|
18
|
+
"gitpush-sync=gitpush.cli:run"
|
|
18
19
|
],
|
|
19
20
|
},
|
|
20
21
|
author="Ganesh Sonawane",
|
|
@@ -0,0 +1,153 @@
|
|
|
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.sync import (
|
|
13
|
+
_auto_generate_message,
|
|
14
|
+
_stage_and_commit,
|
|
15
|
+
_get_tracking_info,
|
|
16
|
+
_has_remote,
|
|
17
|
+
run_sync_flow
|
|
18
|
+
)
|
|
19
|
+
from gitpush.safety import FileChangeStat, get_diffstat_summary
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class TestSyncEngine(unittest.TestCase):
|
|
23
|
+
|
|
24
|
+
def setUp(self):
|
|
25
|
+
# Create bare remote repository
|
|
26
|
+
self.remote_dir = tempfile.mkdtemp()
|
|
27
|
+
subprocess.run(["git", "init", "--bare"], cwd=self.remote_dir, capture_output=True, check=True)
|
|
28
|
+
|
|
29
|
+
# Create local clone / repo
|
|
30
|
+
self.local_dir = tempfile.mkdtemp()
|
|
31
|
+
subprocess.run(["git", "init"], cwd=self.local_dir, capture_output=True, check=True)
|
|
32
|
+
subprocess.run(["git", "config", "user.name", "Test User"], cwd=self.local_dir, capture_output=True, check=True)
|
|
33
|
+
subprocess.run(["git", "config", "user.email", "test@example.com"], cwd=self.local_dir, capture_output=True, check=True)
|
|
34
|
+
subprocess.run(["git", "branch", "-M", "main"], cwd=self.local_dir, capture_output=True, check=True)
|
|
35
|
+
subprocess.run(["git", "remote", "add", "origin", self.remote_dir], cwd=self.local_dir, capture_output=True, check=True)
|
|
36
|
+
|
|
37
|
+
# Initial commit & push to set up origin/main
|
|
38
|
+
file1 = os.path.join(self.local_dir, "initial.txt")
|
|
39
|
+
with open(file1, "w") as f:
|
|
40
|
+
f.write("initial\n")
|
|
41
|
+
subprocess.run(["git", "add", "."], cwd=self.local_dir, check=True, capture_output=True)
|
|
42
|
+
subprocess.run(["git", "commit", "-m", "Initial commit"], cwd=self.local_dir, check=True, capture_output=True)
|
|
43
|
+
subprocess.run(["git", "push", "-u", "origin", "main"], cwd=self.local_dir, check=True, capture_output=True)
|
|
44
|
+
|
|
45
|
+
def tearDown(self):
|
|
46
|
+
shutil.rmtree(self.remote_dir, ignore_errors=True)
|
|
47
|
+
shutil.rmtree(self.local_dir, ignore_errors=True)
|
|
48
|
+
|
|
49
|
+
def test_auto_generate_message(self):
|
|
50
|
+
mock_diff = {
|
|
51
|
+
"modified_count": 1,
|
|
52
|
+
"total_additions": 5,
|
|
53
|
+
"total_deletions": 2,
|
|
54
|
+
"files": [FileChangeStat("foo.py", "M", 5, 2)]
|
|
55
|
+
}
|
|
56
|
+
msg = _auto_generate_message(mock_diff)
|
|
57
|
+
self.assertEqual(msg, "Update foo.py (+5, -2)")
|
|
58
|
+
|
|
59
|
+
mock_diff_multi = {
|
|
60
|
+
"modified_count": 3,
|
|
61
|
+
"total_additions": 10,
|
|
62
|
+
"total_deletions": 4,
|
|
63
|
+
"files": [
|
|
64
|
+
FileChangeStat("a.py", "M", 5, 2),
|
|
65
|
+
FileChangeStat("b.py", "A", 5, 0),
|
|
66
|
+
FileChangeStat("c.py", "D", 0, 2)
|
|
67
|
+
]
|
|
68
|
+
}
|
|
69
|
+
msg_multi = _auto_generate_message(mock_diff_multi)
|
|
70
|
+
self.assertEqual(msg_multi, "Update 3 files (+10, -4)")
|
|
71
|
+
|
|
72
|
+
def test_stage_and_commit(self):
|
|
73
|
+
new_file = os.path.join(self.local_dir, "new_file.txt")
|
|
74
|
+
with open(new_file, "w") as f:
|
|
75
|
+
f.write("hello world\n")
|
|
76
|
+
|
|
77
|
+
success = _stage_and_commit("Test commit", cwd=self.local_dir)
|
|
78
|
+
self.assertTrue(success)
|
|
79
|
+
|
|
80
|
+
# Verify status is clean
|
|
81
|
+
diff = get_diffstat_summary(cwd=self.local_dir)
|
|
82
|
+
self.assertFalse(diff["has_changes"])
|
|
83
|
+
|
|
84
|
+
def test_sync_already_synced(self):
|
|
85
|
+
# Local and remote are currently identical
|
|
86
|
+
ret = run_sync_flow(auto_confirm=True, cwd=self.local_dir)
|
|
87
|
+
self.assertEqual(ret, 0)
|
|
88
|
+
|
|
89
|
+
def test_sync_scenario_a_local_ahead(self):
|
|
90
|
+
# Scenario A: Local commits ahead, remote has 0 new commits -> Safe to push
|
|
91
|
+
new_file = os.path.join(self.local_dir, "feature.txt")
|
|
92
|
+
with open(new_file, "w") as f:
|
|
93
|
+
f.write("feature commit\n")
|
|
94
|
+
subprocess.run(["git", "add", "."], cwd=self.local_dir, check=True, capture_output=True)
|
|
95
|
+
subprocess.run(["git", "commit", "-m", "Add feature"], cwd=self.local_dir, check=True, capture_output=True)
|
|
96
|
+
|
|
97
|
+
ret = run_sync_flow(auto_confirm=True, cwd=self.local_dir)
|
|
98
|
+
self.assertEqual(ret, 0)
|
|
99
|
+
|
|
100
|
+
# Check that remote now has the commit
|
|
101
|
+
res = subprocess.run(["git", "rev-list", "--count", "origin/main...main"], cwd=self.local_dir, capture_output=True, text=True)
|
|
102
|
+
self.assertEqual(res.stdout.strip(), "0")
|
|
103
|
+
|
|
104
|
+
def test_sync_scenario_c_dirty_tree(self):
|
|
105
|
+
# Scenario C: Modified files detected -> auto-commits and pushes
|
|
106
|
+
new_file = os.path.join(self.local_dir, "dirty.txt")
|
|
107
|
+
with open(new_file, "w") as f:
|
|
108
|
+
f.write("dirty worktree\n")
|
|
109
|
+
|
|
110
|
+
ret = run_sync_flow(auto_confirm=True, commit_message="Auto committed dirty file", cwd=self.local_dir)
|
|
111
|
+
self.assertEqual(ret, 0)
|
|
112
|
+
|
|
113
|
+
# Check that worktree is clean and pushed
|
|
114
|
+
diff = get_diffstat_summary(cwd=self.local_dir)
|
|
115
|
+
self.assertFalse(diff["has_changes"])
|
|
116
|
+
|
|
117
|
+
res = subprocess.run(["git", "rev-list", "--count", "origin/main...main"], cwd=self.local_dir, capture_output=True, text=True)
|
|
118
|
+
self.assertEqual(res.stdout.strip(), "0")
|
|
119
|
+
|
|
120
|
+
def test_sync_scenario_b_diverged_rebase(self):
|
|
121
|
+
# Scenario B: Local is ahead, and remote has new commits -> diverged
|
|
122
|
+
# Create a second local clone to make commits and push to remote
|
|
123
|
+
second_local = tempfile.mkdtemp()
|
|
124
|
+
try:
|
|
125
|
+
subprocess.run(["git", "clone", "-b", "main", self.remote_dir, second_local], capture_output=True, check=True)
|
|
126
|
+
subprocess.run(["git", "config", "user.name", "Teammate"], cwd=second_local, capture_output=True, check=True)
|
|
127
|
+
subprocess.run(["git", "config", "user.email", "teammate@example.com"], cwd=second_local, capture_output=True, check=True)
|
|
128
|
+
|
|
129
|
+
with open(os.path.join(second_local, "remote_work.txt"), "w") as f:
|
|
130
|
+
f.write("remote work\n")
|
|
131
|
+
subprocess.run(["git", "add", "."], cwd=second_local, check=True, capture_output=True)
|
|
132
|
+
subprocess.run(["git", "commit", "-m", "Remote teammate commit"], cwd=second_local, check=True, capture_output=True)
|
|
133
|
+
subprocess.run(["git", "push", "origin", "main"], cwd=second_local, check=True, capture_output=True)
|
|
134
|
+
|
|
135
|
+
# Local has its own distinct commit
|
|
136
|
+
with open(os.path.join(self.local_dir, "local_work.txt"), "w") as f:
|
|
137
|
+
f.write("local work\n")
|
|
138
|
+
subprocess.run(["git", "add", "."], cwd=self.local_dir, check=True, capture_output=True)
|
|
139
|
+
subprocess.run(["git", "commit", "-m", "Local commit"], cwd=self.local_dir, check=True, capture_output=True)
|
|
140
|
+
|
|
141
|
+
# Run sync with prefer_rebase=True
|
|
142
|
+
ret = run_sync_flow(auto_confirm=True, prefer_rebase=True, cwd=self.local_dir)
|
|
143
|
+
self.assertEqual(ret, 0)
|
|
144
|
+
|
|
145
|
+
# Both files should exist in local_dir
|
|
146
|
+
self.assertTrue(os.path.exists(os.path.join(self.local_dir, "remote_work.txt")))
|
|
147
|
+
self.assertTrue(os.path.exists(os.path.join(self.local_dir, "local_work.txt")))
|
|
148
|
+
finally:
|
|
149
|
+
shutil.rmtree(second_local, ignore_errors=True)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
if __name__ == "__main__":
|
|
153
|
+
unittest.main()
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
__version__ = "0.4.2"
|
|
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
|