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.
Files changed (26) hide show
  1. {gitpush_tool-0.4.2 → gitpush_tool-0.4.3}/LONG_DESCRIPTION.md +2 -0
  2. {gitpush_tool-0.4.2/gitpush_tool.egg-info → gitpush_tool-0.4.3}/PKG-INFO +3 -1
  3. {gitpush_tool-0.4.2 → gitpush_tool-0.4.3}/README.md +52 -0
  4. gitpush_tool-0.4.3/gitpush/__init__.py +1 -0
  5. {gitpush_tool-0.4.2 → gitpush_tool-0.4.3}/gitpush/cli.py +28 -0
  6. gitpush_tool-0.4.3/gitpush/sync.py +524 -0
  7. {gitpush_tool-0.4.2 → gitpush_tool-0.4.3/gitpush_tool.egg-info}/PKG-INFO +3 -1
  8. {gitpush_tool-0.4.2 → gitpush_tool-0.4.3}/gitpush_tool.egg-info/SOURCES.txt +2 -0
  9. {gitpush_tool-0.4.2 → gitpush_tool-0.4.3}/gitpush_tool.egg-info/entry_points.txt +1 -0
  10. {gitpush_tool-0.4.2 → gitpush_tool-0.4.3}/setup.py +3 -2
  11. gitpush_tool-0.4.3/tests/test_sync.py +153 -0
  12. gitpush_tool-0.4.2/gitpush/__init__.py +0 -1
  13. {gitpush_tool-0.4.2 → gitpush_tool-0.4.3}/LICENSE +0 -0
  14. {gitpush_tool-0.4.2 → gitpush_tool-0.4.3}/MANIFEST.in +0 -0
  15. {gitpush_tool-0.4.2 → gitpush_tool-0.4.3}/gitpush/__doc__.py +0 -0
  16. {gitpush_tool-0.4.2 → gitpush_tool-0.4.3}/gitpush/safety.py +0 -0
  17. {gitpush_tool-0.4.2 → gitpush_tool-0.4.3}/gitpush/status.py +0 -0
  18. {gitpush_tool-0.4.2 → gitpush_tool-0.4.3}/gitpush/undo.py +0 -0
  19. {gitpush_tool-0.4.2 → gitpush_tool-0.4.3}/gitpush_tool.egg-info/dependency_links.txt +0 -0
  20. {gitpush_tool-0.4.2 → gitpush_tool-0.4.3}/gitpush_tool.egg-info/top_level.txt +0 -0
  21. {gitpush_tool-0.4.2 → gitpush_tool-0.4.3}/pyproject.toml +0 -0
  22. {gitpush_tool-0.4.2 → gitpush_tool-0.4.3}/setup.cfg +0 -0
  23. {gitpush_tool-0.4.2 → gitpush_tool-0.4.3}/tests/test_cli.py +0 -0
  24. {gitpush_tool-0.4.2 → gitpush_tool-0.4.3}/tests/test_safety.py +0 -0
  25. {gitpush_tool-0.4.2 → gitpush_tool-0.4.3}/tests/test_status.py +0 -0
  26. {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.2
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.2
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
@@ -2,4 +2,5 @@
2
2
  gitpush = gitpush.cli:run
3
3
  gitpush-guard = gitpush.cli:run
4
4
  gitpush-status = gitpush.cli:run
5
+ gitpush-sync = gitpush.cli:run
5
6
  gitpush-undo = gitpush.cli:run
@@ -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.2",
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