diff --git a/.gitignore b/.gitignore index f040258..f3d8a9e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,37 +1,31 @@ -# Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] *.pyo *.pyd *.py.class -# PyPI Build and Distribution files build/ dist/ *.egg-info/ .eggs/ -# Virtual Environments venv/ env/ .venv/ env.bak/ venv.bak/ -# IDEs and Text Editors .vscode/ .idea/ *.swp *.swo -# OS Generated Files .DS_Store Thumbs.db -# Project-Specific Ignores *.txt .chronotab/ TODO.md docs/ todo/ -.coverage \ No newline at end of file +.coverage diff --git a/CHANGELOG.md b/CHANGELOG.md index 6e86299..0cdd632 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,10 @@ Versions follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] ### Added -- (nothing yet) +- **Auto SSH Commit Signing:** GitGo now uses the SSH key generated during `gitgo user login` to automatically sign all commits using temporary `-c` flags, giving you the Verified badge on GitHub without modifying global git configs. + +### Changed +- Refactored codebase to standardize internal API returns, remove redundant checks, and optimize stash operations. ### Fixed - Fixed GitGo hanging indefinitely during login if an SSH passphrase prompt was triggered invisibly. diff --git a/README.md b/README.md index 4b0471e..7e9c975 100644 --- a/README.md +++ b/README.md @@ -68,7 +68,7 @@ gitgo link https://github.com/username/repo.git "init" - **State management:** Named, indexed stash. Run `state list` to see what you saved. No more `stash@{2}` archaeology. - **Custom defaults:** Store your preferred branch name and default commit message. GitGo picks them up on every run. - **Auto-update checker:** Checks PyPI for newer versions in a background thread. Results are cached for 7 days so startup isn't delayed. -- **SSH auto-setup:** Generates an `ed25519` key, loads it into `ssh-agent`, and opens your GitHub SSH settings page. +- **SSH auto-setup & signing:** Generates an `ed25519` key, loads it into `ssh-agent`, opens your GitHub SSH settings page, and automatically signs all future commits for the verified badge. - **HTTPS-to-SSH conversion:** Detects HTTPS remotes and rewrites them before pushing if SSH is configured. No manual `git remote set-url`. - **Termux support:** Detects the Termux environment, adjusts install paths, uses `termux-open` for browser actions, and patches the dubious ownership Git error. @@ -129,7 +129,7 @@ pip install -e . ### 1. Set Up Your Identity -Run this once on a new machine. GitGo generates an SSH key, adds it to `ssh-agent`, prints the public key, and opens your GitHub SSH settings page. +Run this once on a new machine. GitGo generates an SSH key, adds it to `ssh-agent`, prints the public key, and opens your GitHub SSH settings page so you can add it for both authentication and commit signing. ```bash gitgo user login @@ -295,7 +295,7 @@ gitgo -r # verify GitGo is ready ## How It Works -- **SSH Auto-Setup:** `gitgo user login` generates an `ed25519` SSH key, adds it to `ssh-agent`, prints the public key, and opens `github.com/settings/ssh/new`. +- **SSH Auto-Setup & Signing:** `gitgo user login` generates an `ed25519` SSH key and prompts you to add it to GitHub twice (for authentication and signing). GitGo then injects temporary `-c` flags into every commit to automatically sign them with this key, without touching your global git config. - **HTTPS to SSH Conversion:** If your remote is set to HTTPS and SSH is configured, GitGo rewrites the remote before pushing. No `git remote set-url` required. - **Auto-Update Checker:** Spawns a non-blocking background thread on startup to query PyPI for newer versions. Results are cached locally for 7 days to prevent unnecessary network requests. - **Termux Compatibility:** Detects Termux via environment variables, adjusts binary locations (`$PREFIX/bin`), uses `termux-open` for browser actions, and patches the `detected dubious ownership` Git error. diff --git a/pyproject.toml b/pyproject.toml index 94c011a..2487961 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "pygitgo" -version = "1.6.3" +version = "1.7.0b1" description = "GitGo CLI - Your Fast Git Companion. Simplifies git push, link, stash, and user management." readme = "README.md" license = {text = "GPL-3.0-or-later"} diff --git a/src/pygitgo/auth/account.py b/src/pygitgo/auth/account.py index 2c94058..3c19eda 100644 --- a/src/pygitgo/auth/account.py +++ b/src/pygitgo/auth/account.py @@ -1,6 +1,5 @@ from pygitgo.utils.colors import info, success, warning, error, BLUE, RESET -from pygitgo.utils.executor import run_command -import subprocess +from pygitgo.utils.executor import run_command, command_failed def get_user(): @@ -8,12 +7,12 @@ def get_user(): name = run_command(["git", "config", "--global", "user.name"], allow_fail=True) email = run_command(["git", "config", "--global", "user.email"], allow_fail=True) - if not name or isinstance(name, subprocess.CalledProcessError): + if not name or command_failed(name): name = None - if not email or isinstance(email, subprocess.CalledProcessError): + if not email or command_failed(email): email = None return name, email - except: + except Exception: return None, None def set_user(name, email): diff --git a/src/pygitgo/auth/manager.py b/src/pygitgo/auth/manager.py index 507ab44..93bf582 100644 --- a/src/pygitgo/auth/manager.py +++ b/src/pygitgo/auth/manager.py @@ -32,11 +32,15 @@ def login(): print("=" * len(pub_key) + "\n") info("Copy the key above (between the lines).") + info("You need to add this key TWICE on GitHub:") + info(" 1. Once as 'Authentication Key' (so you can push and pull)") + info(" 2. Once as 'Signing Key' (so your commits show as Verified)") + info("Both entries use the exact same key text.") ssh_utils.open_github_settings() - + input( - "After pasting your key on GitHub and clicking 'Add SSH Key',\n" + "After adding both keys on GitHub,\n" "come back here and press Enter to verify the connection..." ) diff --git a/src/pygitgo/auth/ssh_utils.py b/src/pygitgo/auth/ssh_utils.py index 16c8908..37b5d6c 100644 --- a/src/pygitgo/auth/ssh_utils.py +++ b/src/pygitgo/auth/ssh_utils.py @@ -1,10 +1,14 @@ -from pygitgo.utils.colors import info, success, warning, error -from pygitgo.utils.executor import run_command from pygitgo.utils import platform_utils +from pygitgo.utils.colors import info, success, warning, error +from pygitgo.utils.executor import run_command, command_failed +from pygitgo.exceptions import GitCommandError from pathlib import Path +import webbrowser import subprocess -import sys +import shutil import os +import re + SSH_TIMEOUT_SECONDS = 10 @@ -25,7 +29,7 @@ def ensure_github_known_host(): info("Adding GitHub to known_hosts...") result = run_command(["ssh-keyscan", "-H", "github.com"], allow_fail=True, return_complete=True) - if not isinstance(result, Exception) and result.stdout and "github.com" in result.stdout: + if not command_failed(result) and result.stdout and "github.com" in result.stdout: with open(known_hosts, "a") as f: f.write(result.stdout) if not result.stdout.endswith("\n"): @@ -34,39 +38,29 @@ def ensure_github_known_host(): else: warning("Could not automatically add GitHub to known_hosts. You might be prompted.") -def check_connection(): - ensure_github_known_host() +def _get_github_ssh_response(): try: result = subprocess.run( ["ssh", "-T", "-o", "BatchMode=yes", "git@github.com"], - capture_output=True, - text=True, - timeout=SSH_TIMEOUT_SECONDS, - stdin=subprocess.DEVNULL, + capture_output=True, text=True, + timeout=SSH_TIMEOUT_SECONDS, stdin=subprocess.DEVNULL, ) - output = (result.stderr or "") + (result.stdout or "") - return "successfully authenticated" in output + return (result.stderr or "") + (result.stdout or "") except (subprocess.TimeoutExpired, OSError): - return False + return None -def get_github_username(): - try: - result = subprocess.run( - ["ssh", "-T", "-o", "BatchMode=yes", "git@github.com"], - capture_output=True, - text=True, - timeout=SSH_TIMEOUT_SECONDS, - stdin=subprocess.DEVNULL, - ) - output = (result.stderr or "") + (result.stdout or "") +def check_connection(): + ensure_github_known_host() + output = _get_github_ssh_response() + return output is not None and "successfully authenticated" in output - if "Hi " in output and "!" in output: - try: - return output.split("Hi ")[1].split("!")[0] - except (IndexError, ValueError): - return None - except (subprocess.TimeoutExpired, OSError): - pass +def get_github_username(): + output = _get_github_ssh_response() + if output and "Hi " in output and "!" in output: + try: + return output.split("Hi ")[1].split("!")[0] + except (IndexError, ValueError): + pass return None def get_ssh_key_path(): @@ -97,7 +91,7 @@ def generate_ssh_key(email): try: run_command(["ssh-add", str(key_path)], allow_fail=True) - except (subprocess.CalledProcessError, OSError): + except (GitCommandError, OSError): pass return key_path @@ -107,33 +101,23 @@ def open_github_settings(): opened = False try: - if platform_utils.is_windows(): - os.system(f"start {url}") - opened = True - elif platform_utils.is_termux(): - os.system(f"termux-open {url}") - opened = True - elif platform_utils.is_linux() or platform_utils.is_macos(): - exit_code = os.system(f"xdg-open {url} 2>/dev/null") - opened = exit_code == 0 + if platform_utils.is_termux(): + if shutil.which("termux-open"): + subprocess.run(["termux-open", url], check=False) + opened = True else: - import webbrowser - webbrowser.open(url) - opened = True + opened = webbrowser.open(url) except Exception: opened = False if not opened: warning("Could not open browser automatically.") - info(f"\nIf the browser did not open, visit this URL manually:") + info("\nIf the browser did not open, visit this URL manually:") print(f"\n {url}\n") -def convert_https_to_ssh(url): - - import re - +def convert_https_to_ssh(url): pattern = r'^https?://github\.com/([^/]+)/([^/]+?)(?:\.git)?/?$' match = re.match(pattern, url.strip()) diff --git a/src/pygitgo/commands/git_operations.py b/src/pygitgo/commands/git_operations.py index e4eb330..88ccbba 100644 --- a/src/pygitgo/commands/git_operations.py +++ b/src/pygitgo/commands/git_operations.py @@ -1,17 +1,16 @@ -from pygitgo.auth.ssh_utils import convert_https_to_ssh, is_ssh_url, check_connection +from pygitgo.auth.ssh_utils import convert_https_to_ssh, get_ssh_key_path, is_ssh_url, check_connection from pygitgo.utils.colors import info, success, warning, error -from pygitgo.utils.executor import run_command +from pygitgo.utils.executor import run_command, command_failed from pygitgo.utils.config import get_config -from pygitgo.exceptions import GitGoError +from pygitgo.exceptions import GitGoError, GitCommandError from argparse import Namespace -import subprocess -import sys +from pathlib import Path import os def get_status_content(): status = run_command(["git", "status", "--porcelain"], allow_fail=True) - if isinstance(status, subprocess.CalledProcessError) or not status.strip(): + if command_failed(status) or not status.strip(): raise GitGoError("\nWorking tree is clean. Nothing to commit.\n") return status @@ -24,7 +23,7 @@ def get_current_branch(): def get_main_branch(): main_branch = run_command(['git', 'remote', 'show', 'origin'], allow_fail=True) default_main_branch = get_config("default-branch", "main") - if isinstance(main_branch, subprocess.CalledProcessError): + if command_failed(main_branch): return default_main_branch return main_branch.split("HEAD branch:")[-1].strip().splitlines()[0].strip() if "HEAD branch:" in main_branch else default_main_branch @@ -35,7 +34,7 @@ def is_branch_exist(branch): def git_new_branch(branch): result = run_command(["git", "checkout", "-b", branch], loading_msg=f"Creating branch '{branch}'...") - if isinstance(result, subprocess.CalledProcessError): + if command_failed(result): error(f"Failed to create branch '{branch}'! It may already exist.") choice = input("\nWould you like to jump to the existing branch instead? (y/n): ").strip().lower() if choice == "y": @@ -49,15 +48,26 @@ def git_new_branch(branch): return branch -def git_commit(commit_message): +def _get_signing_flags(): + key_path = get_ssh_key_path() + if not key_path.exists(): + return [] + return [ + "-c", "gpg.format=ssh", + "-c", f"user.signingkey={key_path}", + ] + +def git_commit(commit_message, loading_msg="Commiting changes..."): status_result = run_command(["git", "status", "--porcelain"], allow_fail=True) - if isinstance(status_result, subprocess.CalledProcessError) or not status_result.strip(): + if command_failed(status_result) or not status_result.strip(): return False run_command(["git", "add", "."], loading_msg="Staging files...") clean_message = commit_message.strip('"\'') - - run_command(["git", "commit", "-m", clean_message], loading_msg="Commiting changes...") + + signing_flags = _get_signing_flags() + commit_command = ["git"] + signing_flags + ["commit", "-S", "-m", clean_message] + run_command(commit_command, loading_msg=loading_msg) return True @@ -70,7 +80,7 @@ def git_init(): default_main_branch = get_config("default-branch", "main") result = run_command(["git", "init", "-b", default_main_branch], allow_fail=True, loading_msg="Initializing git repository...") - if isinstance(result, subprocess.CalledProcessError): + if command_failed(result): run_command(["git", "init"], loading_msg="Initializing git repository...") run_command(["git", "checkout", "-b", default_main_branch], allow_fail=True) @@ -82,7 +92,7 @@ def add_remote_origin(repo_url): clean_url = repo_url.strip('"\'') existing_remote = run_command(["git", "remote", "get-url", "origin"], allow_fail=True) - if not isinstance(existing_remote, subprocess.CalledProcessError): + if not command_failed(existing_remote): warning(f"Remote origin already exists: {existing_remote}") run_command(["git", "remote", "set-url", "origin", clean_url], loading_msg="Updating remote URL...") else: @@ -94,7 +104,7 @@ def add_remote_origin(repo_url): def confirm_remote_link(): test_result = run_command(["git", "ls-remote", "origin"], allow_fail=True, loading_msg="Testing connection to remote...") - if isinstance(test_result, subprocess.CalledProcessError): + if command_failed(test_result): error("Failed to connect to remote repository!") warning("Please check your repository URL and network connection.") return False @@ -106,7 +116,7 @@ def confirm_remote_link(): def create_main_branch(): current_branch = run_command(["git", "branch", "--show-current"], allow_fail=True) - if isinstance(current_branch, subprocess.CalledProcessError) or not current_branch.strip(): + if command_failed(current_branch) or not current_branch.strip(): run_command(["git", "checkout", "-b", "main"], loading_msg="Setting default branch to 'main'...") elif current_branch.strip() != "main": run_command(["git", "branch", "-m", "main"], loading_msg=f"Renaming branch '{current_branch.strip()}' to 'main'...") @@ -136,16 +146,16 @@ def check_and_sync_branch(branch): success("Branch is up to date or ahead of remote.") else: success("Branch is already up to date.") - except (subprocess.CalledProcessError, ValueError): + except (GitCommandError, ValueError): warning("Remote branch doesn't exist yet. First push will create it.") - except (subprocess.CalledProcessError, OSError): + except (GitCommandError, OSError): warning("Could not fetch from remote. Proceeding with push...") def git_push(branch): remote_url = run_command(["git", "remote", "get-url", "origin"], allow_fail=True) - if not isinstance(remote_url, subprocess.CalledProcessError) and remote_url: + if not command_failed(remote_url) and remote_url: remote_url = remote_url.strip() if not is_ssh_url(remote_url) and check_connection(): @@ -156,7 +166,7 @@ def git_push(branch): try: run_command(["git", "push", "-u", "origin", branch], loading_msg=f"Pushing to remote branch '{branch}'...") - except (subprocess.CalledProcessError, OSError) as e: + except (GitCommandError, OSError) as e: error("Failed to push to remote repository!") warning("Please check your network connection, remote URL, and authentication.") if "rebase in progress" in str(e): @@ -167,7 +177,7 @@ def git_push(branch): def handle_rebase(): status = run_command(["git", "status"], allow_fail=True) - if isinstance(status, subprocess.CalledProcessError): + if command_failed(status): return False if "rebase in progress" in status or "rebase" in status.lower(): diff --git a/src/pygitgo/commands/jump.py b/src/pygitgo/commands/jump.py index 824a71c..dd50b26 100644 --- a/src/pygitgo/commands/jump.py +++ b/src/pygitgo/commands/jump.py @@ -1,11 +1,13 @@ -from pygitgo.exceptions import GitGoError from pygitgo.commands.git_operations import ( - is_branch_exist, get_current_branch, git_new_branch, get_main_branch + is_branch_exist, get_current_branch, git_new_branch, get_main_branch, +) +from pygitgo.commands.stash_operation import ( + git_stash_pop, git_stash_push, git_stash_apply, git_stash_drop ) from pygitgo.utils.colors import warning, info, success, error -from pygitgo.utils.executor import run_command -import subprocess -import sys +from pygitgo.utils.executor import run_command, command_failed +from pygitgo.exceptions import GitGoError +from json import load def undo_jump_operation(original_branch, stashed_code, created_branch=None): @@ -17,7 +19,9 @@ def undo_jump_operation(original_branch, stashed_code, created_branch=None): run_command(['git', 'checkout', original_branch], loading_msg=f"Jumping you back to the original branch '{original_branch}'...") if stashed_code: - run_command(["git", "stash", "pop"], loading_msg="Restoring your unsaved changes...") + pop_result = git_stash_pop() + if not pop_result: + warning("\nCould not restore your unsaved changes automatically. Run 'gitgo state list' to recover them.\n") success(f"\nCanceled safely!") success(f"You are back on your original branch '{original_branch}', and your code is totally safe.\n") @@ -27,14 +31,14 @@ def jump_operation(args): target_branch = args.branch - original_branch = get_current_branch().strip() + original_branch = get_current_branch() if original_branch == target_branch: warning(f"\nYou are already on branch '{target_branch}'.\n") return has_changes = run_command(['git', 'status', '--porcelain'], allow_fail=True, loading_msg="Checking for uncommitted changes...") - if isinstance(has_changes, subprocess.CalledProcessError): + if command_failed(has_changes): raise GitGoError("\nUnable to check for uncommitted changes. Please ensure you're in a valid git repository.\n") stashed_code = False @@ -45,8 +49,8 @@ def jump_operation(args): warning("\nYou cannot switch branches with unsaved changes. Jump canceled.\n") return else: - stash_result = run_command(["git", "stash", "push", "-u", "-m", "GitGo Jump Auto-Stash"], allow_fail=True, loading_msg="Saving your changes before jumping...") - if isinstance(stash_result, subprocess.CalledProcessError): + stash_result = git_stash_push(label="GitGo Jump Auto-Stash", loading_msg="Saving your changes before jumping...") + if not stash_result: warning("\nFailed to save your changes. Please resolve any issues and try again.") raise GitGoError() info("\nYour changes have been saved. Jumping to the new branch...") @@ -60,7 +64,9 @@ def jump_operation(args): if user_input != 'y': info("Exiting without jumping...\n") if stashed_code: - run_command(["git", "stash", "pop"], loading_msg="Putting your unsaved changes back...") + pop_result = git_stash_pop(loading_msg="Putting your unsaved changes back...") + if not pop_result: + warning("\nCould not restore your unsaved changes automatically. Run 'gitgo state list' to recover them.") return git_new_branch(target_branch) @@ -71,7 +77,7 @@ def jump_operation(args): main_branch = get_main_branch() get_origin_updates = run_command(['git', 'pull', 'origin', main_branch], allow_fail=True, loading_msg=f"Downloading the latest updates from '{main_branch}'...") - if isinstance(get_origin_updates, subprocess.CalledProcessError): + if command_failed(get_origin_updates): warning(f"\nFailed to pull updates from '{main_branch}'. Make sure you have internet or the remote branch exists.") user_input = input("Do you want to stay on the new branch without the latest updates? (y/n): ").strip().lower() if user_input != 'y': @@ -81,8 +87,8 @@ def jump_operation(args): success(f"\nOkay! You are on the new branch, but without the latest updates from '{main_branch}'.") if stashed_code: - apply_result = run_command(['git', 'stash', 'apply'], allow_fail=True, loading_msg="Unpacking your unsaved changes...") - if isinstance(apply_result, subprocess.CalledProcessError): + apply_result = git_stash_apply(loading_msg="Unpacking your unsaved changes...") + if not apply_result: error("\nSTOP! There is a 'Merge Conflict'.") warning("Your unsaved code clashes with the new code from 'main'.\n") info("Option [Y]: Stay here and fix the red conflict lines yourself.") @@ -98,7 +104,9 @@ def jump_operation(args): info("Your stash backup is still saved. Run 'gitgo state list' to see it.\n") return else: - run_command(["git", "stash", "drop"], allow_fail=True, loading_msg="Cleaning up the temporary stash...") + drop_result = git_stash_drop(loading_msg="Cleaning up the temporary stash...") + if not drop_result: + warning("\nCould not clean up the temporary stash. Run 'gitgo state list' to remove it manually.") success(f"\nSuccess! You are now on '{target_branch}'.") success("Your unsaved code was moved here safely!\n") return diff --git a/src/pygitgo/commands/pull.py b/src/pygitgo/commands/pull.py index 2dda069..d2c5203 100644 --- a/src/pygitgo/commands/pull.py +++ b/src/pygitgo/commands/pull.py @@ -1,10 +1,8 @@ -from pygitgo.exceptions import GitGoError -from pygitgo.utils.colors import success, warning, error, info -from pygitgo.utils.executor import run_command -from pygitgo.exceptions import GitCommandError from pygitgo.commands.git_operations import get_current_branch -import subprocess -import sys +from pygitgo.utils.colors import success, warning, error, info +from pygitgo.exceptions import GitCommandError, GitGoError +from pygitgo.utils.executor import run_command, command_failed + def pull_operation(args): branch = args.branch @@ -15,7 +13,7 @@ def pull_operation(args): try: remote_refs = run_command(["git", "ls-remote", "--heads", "origin", branch], allow_fail=True) - if isinstance(remote_refs, subprocess.CalledProcessError) or not remote_refs.strip(): + if command_failed(remote_refs) or not remote_refs.strip(): error(f"\nFailed! The branch '{branch}' does not exist on the remote server.") warning("You might need to push your local branch first.\n") raise GitGoError() diff --git a/src/pygitgo/commands/staging.py b/src/pygitgo/commands/staging.py index c325d49..0ecefd6 100644 --- a/src/pygitgo/commands/staging.py +++ b/src/pygitgo/commands/staging.py @@ -1,8 +1,6 @@ from pygitgo.utils.colors import success, warning, error -from pygitgo.utils.executor import run_command +from pygitgo.utils.executor import run_command, command_failed from pick import pick -import subprocess -import sys STATUS_LABELS = { @@ -16,7 +14,7 @@ def get_changed_files(): status = run_command(["git", "status", "--porcelain"], allow_fail=True) - if isinstance(status, subprocess.CalledProcessError) or not status.strip(): + if command_failed(status) or not status.strip(): return [] files = [] diff --git a/src/pygitgo/commands/stash_operation.py b/src/pygitgo/commands/stash_operation.py new file mode 100644 index 0000000..42ee2b3 --- /dev/null +++ b/src/pygitgo/commands/stash_operation.py @@ -0,0 +1,49 @@ +from pygitgo.utils.executor import command_failed, run_command + + +def git_stash_push(label="GitGo Auto-Stash", loading_msg="Saving your changes..."): + result = run_command( + ["git", "stash", "push", "-u", "-m", label], + allow_fail=True, + loading_msg=loading_msg + ) + if command_failed(result): + return False + if isinstance(result, str) and "No local changes to save" in result: + return False + return True + +def git_stash_pop(loading_msg="Restoring your saved changes..."): + result = run_command( + ["git", "stash", "pop"], + allow_fail=True, + loading_msg=loading_msg + ) + return not command_failed(result) + +def git_stash_apply(stash_id=None, loading_msg="Applying saved changes..."): + command = ["git", "stash", "apply"] + if stash_id is not None: + command.append(f"stash@{{{stash_id}}}") + + result = run_command(command, allow_fail=True, loading_msg=loading_msg) + return not command_failed(result) + +def git_stash_drop(stash_id=None, loading_msg="Cleaning up stash..."): + command = ["git", "stash", "drop"] + if stash_id is not None: + command.append(f"stash@{{{stash_id}}}") + + result = run_command(command, allow_fail=True, loading_msg=loading_msg) + return not command_failed(result) + +def git_stash_list(loading_msg="Fetching stash list..."): + return run_command([ + "git", "stash", "list", + "--date=format:%Y-%m-%d %H:%M:%S", + "--pretty=%gd||%cd||%s" + ], allow_fail=True) + +def git_stash_clear(loading_msg="Clearing all stashes..."): + result = run_command(["git", "stash", "clear"], allow_fail=True) + return not command_failed(result) diff --git a/src/pygitgo/commands/state.py b/src/pygitgo/commands/state.py index cfe1031..0bf8f04 100644 --- a/src/pygitgo/commands/state.py +++ b/src/pygitgo/commands/state.py @@ -1,7 +1,10 @@ -from pygitgo.exceptions import GitGoError from pygitgo.utils.colors import info, highlight, error, warning, success -from pygitgo.utils.executor import run_command -import sys +from pygitgo.commands.stash_operation import ( + git_stash_apply, git_stash_clear, git_stash_drop, + git_stash_list, git_stash_push +) +from pygitgo.utils.executor import run_command, command_failed +from pygitgo.exceptions import GitGoError ALIASES = { @@ -12,15 +15,10 @@ } def all_save_state(): - output = run_command([ - "git", "stash", "list", - "--date=format:%Y-%m-%d %H:%M:%S", - "--pretty=%gd||%cd||%s" - ]) + output = git_stash_list() - if not output: - info("\nNo saved states found.\n") - return + if command_failed(output) or not output: + return [] save_states = [] @@ -45,8 +43,12 @@ def all_save_state(): return save_states -def display_save_states(): - save_states = all_save_state() +def display_save_states(save_state=None): + save_states = save_state if save_state is not None else all_save_state() + + if not save_states: + info("\nNo saved states found.\n") + return print("\nID | Date | Saved State") print("-" * 60) @@ -82,7 +84,7 @@ def ask_state_id(save_states): proceed = False state_id = None - display_save_states() + display_save_states(save_states) info("\nEnter the ID (or 'q' to cancel): ") while not proceed: @@ -113,52 +115,68 @@ def load_state(state_id=None): if not proceed: state_id = ask_state_id(save_states) + if not state_id: + return - run_command(["git", "stash", "apply", str(int(state_id) - 1)]) - + apply_result = git_stash_apply(stash_id=str(int(state_id) - 1)) + if not apply_result: + error(f"\nFailed to load state. There may be a conflict with your current changes.\n") + raise GitGoError() success(f"\nState '{save_states[int(state_id) - 1]['message']}' loaded successfully.\n") def save_state(state_name=None): if not state_name: state_name = "Auto-Save" - - output = run_command(["git", "stash", "push", "-m", state_name], allow_fail=True) - if isinstance(output, Exception): - error(f"\nFailed to save state '{state_name}'.\n") - elif "No local changes to save" in output: + + has_changes = run_command(['git', 'status', '--porcelain'], allow_fail=True) + if not command_failed(has_changes) and not has_changes.strip(): warning("\nNo local changes to save.\n") + return + + output = git_stash_push(label=state_name) + if not output: + error(f"\nFailed to save state '{state_name}'.\n") else: success(f"\nState '{state_name}' saved successfully.\n") def delete_state(identifier=None): + save_states = all_save_state() + if not save_states: + warning("\nNo saved states to delete.\n") + return + if not identifier: - state_id = ask_state_id(all_save_state()) + state_id = ask_state_id(save_states) + if not state_id: + return else: if identifier == '-a': confirm = input("\nAre you sure you want to delete all saved states? (y/n): ").strip().lower() - if confirm.lower() == 'y': - run_command(["git", "stash", "clear"]) - success("\nAll saved states deleted successfully.\n") - return + if confirm == 'y': + clear_result = git_stash_clear() + if not clear_result: + error("\nFailed to delete all saved states.\n") + else: + success("\nAll saved states deleted successfully.\n") else: warning("\nDelete operation cancelled by user.\n") - return - + return elif not identifier.isdigit(): raise GitGoError("\nInvalid input. Please enter a valid state ID.\n") state_id = identifier - if not validate_state_id(state_id, all_save_state()): + if not validate_state_id(state_id, save_states): raise GitGoError() - run_command(["git", "stash", "drop", str(int(state_id) - 1)]) + drop_result = git_stash_drop(stash_id=str(int(state_id) - 1)) + if not drop_result: + error(f"\nFailed to delete state with ID '{state_id}'.\n") + raise GitGoError() success(f"\nState with ID '{state_id}' deleted successfully.\n") - - def state_operations(args): action = ALIASES.get(args.action, args.action) identifier = getattr(args, "identifier", None) @@ -172,4 +190,5 @@ def state_operations(args): elif action == "delete": delete_state(identifier) else: - raise GitGoError(f"\nUnknown state operation: {action}\n") \ No newline at end of file + raise GitGoError(f"\nUnknown state operation: {action}\n") + \ No newline at end of file diff --git a/src/pygitgo/commands/undo.py b/src/pygitgo/commands/undo.py index 86a458a..1c98914 100644 --- a/src/pygitgo/commands/undo.py +++ b/src/pygitgo/commands/undo.py @@ -1,8 +1,6 @@ -from pygitgo.exceptions import GitGoError -from pygitgo.utils.colors import success, warning, error, info -from pygitgo.exceptions import GitCommandError +from pygitgo.exceptions import GitCommandError, GitGoError +from pygitgo.utils.colors import success, warning, info from pygitgo.utils.executor import run_command -import sys def undo_commit(): diff --git a/src/pygitgo/main.py b/src/pygitgo/main.py index f9827b0..5f7e452 100644 --- a/src/pygitgo/main.py +++ b/src/pygitgo/main.py @@ -3,20 +3,19 @@ confirm_remote_link, git_push, get_current_branch, is_branch_exist ) from pygitgo.commands.staging import get_changed_files, display_file_picker, selective_stage +from pygitgo.utils.update_checker import check_for_updates_background +from pygitgo.utils.executor import run_command, command_failed from pygitgo.utils.colors import info, success, warning, error from pygitgo.utils.config import get_config, config_operation from pygitgo.exceptions import GitCommandError, GitGoError -from pygitgo.utils.update_checker import check_for_updates_background from pygitgo.utils.setup import ensure_first_run_setup from pygitgo.commands.state import state_operations from pygitgo.commands.undo import undo_operations from pygitgo.commands.pull import pull_operation from pygitgo.commands.jump import jump_operation -from pygitgo.utils.executor import run_command from pygitgo.auth.manager import login, logout from pygitgo.auth.account import get_user from argparse import Namespace -import subprocess import argparse import sys import re @@ -52,11 +51,7 @@ def link_operation(args): if not git_init(): return - run_command(["git", "add", "."], loading_msg="Staging files...") - success("Files staged for commit.") - - clean_message = commit_message.strip('"\'') - run_command(["git", "commit", "-m", clean_message], loading_msg="Creating initial commit...") + git_commit(commit_message, loading_msg="Creating initial commit...") success("Initial commit created.") add_remote_origin(repo_url) @@ -68,17 +63,17 @@ def link_operation(args): current_branch = get_current_branch() main_branch = get_config("default-branch", "main") - if current_branch.strip() != main_branch: - run_command(["git", "branch", "-m", main_branch], loading_msg=f"Renaming branch '{current_branch.strip()}' to '{main_branch}'...") + if current_branch != main_branch: + run_command(["git", "branch", "-m", main_branch], loading_msg=f"Renaming branch '{current_branch}' to '{main_branch}'...") current_branch = main_branch remote_refs = run_command(["git", "ls-remote", "--heads", "origin", main_branch], allow_fail=True, loading_msg="Checking remote branches...") - if not isinstance(remote_refs, subprocess.CalledProcessError) and remote_refs.strip(): + if not command_failed(remote_refs) and remote_refs.strip(): pull_result = run_command( ["git", "pull", "origin", main_branch, "--allow-unrelated-histories", "--no-edit"], allow_fail=True, loading_msg="Pulling and merging remote content..." ) - if isinstance(pull_result, subprocess.CalledProcessError): + if command_failed(pull_result): error("Failed to merge remote content. You may need to resolve conflicts manually.") warning(f"Run: git pull origin {main_branch} --allow-unrelated-histories") warning(f"Then: gitgo push {main_branch} 'your message'\n") @@ -144,20 +139,9 @@ def push_operation(args): return selective_stage(selected) - clean_message = message.strip('"\'') - run_command(["git", "commit", "-m", clean_message], loading_msg="Commiting changes...") + git_commit(message, loading_msg="Commiting selected files...") + git_push(branch) - - leftover_files = [f for f in files if f["path"] not in selected] - if leftover_files: - print() - warning(f"You have {len(leftover_files)} uncommitted file(s) left in your working directory:") - for f in leftover_files: - info(f" - {f['path']}") - print() - save_choice = input("Would you like to save these leftovers to a GitGo State for later? (y/n): ").lower() - if save_choice == 'y': - state_operations(Namespace(action="save", identifier=f"Leftovers from: {clean_message}")) else: commit_made = git_commit(message) @@ -166,7 +150,7 @@ def push_operation(args): else: try: unpushed = run_command(["git", "log", "--oneline", f"origin/{branch}..HEAD"], allow_fail=True, loading_msg="Checking for unpushed commits...") - if not isinstance(unpushed, subprocess.CalledProcessError) and unpushed.strip(): + if not command_failed(unpushed) and unpushed.strip(): warning("\nNo changes to commit, but found unpushed commits. Pushing to remote...") git_push(branch) else: diff --git a/src/pygitgo/utils/config.py b/src/pygitgo/utils/config.py index 153a8b1..91d878c 100644 --- a/src/pygitgo/utils/config.py +++ b/src/pygitgo/utils/config.py @@ -1,6 +1,5 @@ -from pygitgo.utils.executor import run_command -from pygitgo.utils.colors import * -import subprocess +from pygitgo.utils.executor import command_failed, run_command +from pygitgo.utils.colors import error, warning, success, info def get_config(key, fallback_value): @@ -9,7 +8,7 @@ def get_config(key, fallback_value): result = run_command(['git', 'config', '--global', config_key], allow_fail=True) - if not result or isinstance(result, subprocess.CalledProcessError): + if not result or command_failed(result): return fallback_value return result.strip() @@ -21,7 +20,7 @@ def set_config(key, value): result = run_command(['git', 'config', '--global', config_key, value], allow_fail=True) - if isinstance(result, subprocess.CalledProcessError): + if command_failed(result): error(f"\nFailed to save configuration for '{key}'.") return False diff --git a/src/pygitgo/utils/executor.py b/src/pygitgo/utils/executor.py index c2a3b6a..6fc73a7 100644 --- a/src/pygitgo/utils/executor.py +++ b/src/pygitgo/utils/executor.py @@ -3,6 +3,7 @@ from yaspin import yaspin import subprocess import os +import re def run_command(command, allow_fail=False, return_complete=False, loading_msg=None): @@ -51,7 +52,6 @@ def run_command(command, allow_fail=False, return_complete=False, loading_msg=No warning("\nThis is a known Git security feature in shared environments (like Termux).") info("GitGo can fix this for you by adding this directory to your safe list.") - import re path_match = re.search(r"repository at '(.+)'", stderr) repo_path = path_match.group(1) if path_match else os.getcwd() @@ -75,3 +75,7 @@ def run_command(command, allow_fail=False, return_complete=False, loading_msg=No return e raise GitCommandError(command, stderr=stderr, returncode=returncode) + +def command_failed(result): + # Returns True if run_command had error + return isinstance(result, Exception) diff --git a/src/pygitgo/utils/setup.py b/src/pygitgo/utils/setup.py index 1ef8299..180c006 100644 --- a/src/pygitgo/utils/setup.py +++ b/src/pygitgo/utils/setup.py @@ -3,7 +3,6 @@ from pygitgo.utils.config import get_config, set_config from pygitgo.utils.colors import error, info import shutil -import sys def check_git_installed(): diff --git a/tests/test_git_operations.py b/tests/test_git_operations.py index db1df8e..58e8d5c 100644 --- a/tests/test_git_operations.py +++ b/tests/test_git_operations.py @@ -51,13 +51,14 @@ def test_git_branch_exists_jump_no(mocker): fake_jump.assert_not_called() def test_git_commit(mocker): - fake_run = mocker.patch("pygitgo.commands.git_operations.run_command")\ + mocker.patch("pygitgo.commands.git_operations._get_signing_flags", return_value=[]) + fake_run = mocker.patch("pygitgo.commands.git_operations.run_command") result = git_commit("Testing the commit feature") assert result == True - fake_run.assert_called_with( - ['git', 'commit', '-m', 'Testing the commit feature'], + fake_run.assert_any_call( + ['git', 'commit', '-S', '-m', 'Testing the commit feature'], loading_msg="Commiting changes..." ) @@ -432,9 +433,10 @@ def test_check_and_sync_branch_need_sync(mocker): ] def test_check_and_sync_branch_no_remote(mocker): + from pygitgo.exceptions import GitCommandError fake_run = mocker.patch( 'pygitgo.commands.git_operations.run_command', - side_effect=subprocess.CalledProcessError(1, 'git') + side_effect=GitCommandError(['git', 'fetch'], stderr='error', returncode=1) ) fake_warning = mocker.patch('pygitgo.commands.git_operations.warning') @@ -449,9 +451,10 @@ def test_check_and_sync_branch_no_remote(mocker): ) def test_check_and_sync_branch_remote_not_exist(mocker): + from pygitgo.exceptions import GitCommandError fake_run = mocker.patch( 'pygitgo.commands.git_operations.run_command', - side_effect=[None,subprocess.CalledProcessError(1, 'git')] + side_effect=[None, GitCommandError(['git', 'rev-parse'], stderr='error', returncode=1)] ) fake_warning = mocker.patch('pygitgo.commands.git_operations.warning') diff --git a/tests/test_jump.py b/tests/test_jump.py index 8488312..eb1e13d 100644 --- a/tests/test_jump.py +++ b/tests/test_jump.py @@ -1,7 +1,7 @@ from pygitgo.commands.jump import undo_jump_operation, jump_operation +from pygitgo.exceptions import GitCommandError, GitGoError from conftest import capture_system_exit_code from argparse import Namespace -import subprocess import pytest @@ -9,265 +9,258 @@ def make_args(branch): return Namespace(branch=branch) def test_undo_jump_operation_no_stash(mocker): - fake_run = mocker.patch('pygitgo.commands.jump.run_command', return_value="") - + fake_run = mocker.patch('pygitgo.commands.jump.run_command', return_value='') + fake_pop = mocker.patch('pygitgo.commands.jump.git_stash_pop', return_value=True) + undo_jump_operation("original_branch", False) - - fake_run.assert_any_call(["git", "reset", "--hard", "HEAD"], loading_msg="Canceling... Putting your files back exactly how they were...") - fake_run.assert_any_call(['git', 'checkout', "original_branch"], loading_msg="Jumping you back to the original branch 'original_branch'...") + + fake_run.assert_any_call( + ["git", "reset", "--hard", "HEAD"], + loading_msg="Canceling... Putting your files back exactly how they were..." + ) + fake_run.assert_any_call( + ['git', 'checkout', "original_branch"], + loading_msg="Jumping you back to the original branch 'original_branch'..." + ) + fake_pop.assert_not_called() + def test_undo_jump_operation_with_stash(mocker): - fake_run = mocker.patch('pygitgo.commands.jump.run_command', return_value="") - + fake_run = mocker.patch('pygitgo.commands.jump.run_command', return_value='') + fake_pop = mocker.patch('pygitgo.commands.jump.git_stash_pop', return_value=True) + undo_jump_operation("original_branch", True) - - fake_run.assert_any_call(["git", "reset", "--hard", "HEAD"], loading_msg="Canceling... Putting your files back exactly how they were...") - fake_run.assert_any_call(['git', 'checkout', "original_branch"], loading_msg="Jumping you back to the original branch 'original_branch'...") - fake_run.assert_any_call(["git", "stash", "pop"], loading_msg="Restoring your unsaved changes...") + + fake_run.assert_any_call( + ["git", "reset", "--hard", "HEAD"], + loading_msg="Canceling... Putting your files back exactly how they were..." + ) + fake_run.assert_any_call( + ['git', 'checkout', "original_branch"], + loading_msg="Jumping you back to the original branch 'original_branch'..." + ) + fake_pop.assert_called_once() + def test_undo_jump_operation_deletes_ghost_branch(mocker): - fake_run = mocker.patch('pygitgo.commands.jump.run_command', return_value="") - + fake_run = mocker.patch('pygitgo.commands.jump.run_command', return_value='') + fake_pop = mocker.patch('pygitgo.commands.jump.git_stash_pop', return_value=True) + undo_jump_operation("original_branch", True, created_branch="ghost-branch") - - fake_run.assert_any_call(['git', 'checkout', "original_branch"], loading_msg="Jumping you back to the original branch 'original_branch'...") - fake_run.assert_any_call(["git", "branch", "-D", "ghost-branch"], allow_fail=True, loading_msg="Removing the empty branch 'ghost-branch'...") - fake_run.assert_any_call(["git", "stash", "pop"], loading_msg="Restoring your unsaved changes...") -def test_jump_operation_same_branch(mocker): - main_branch = 'main' - fake_run = mocker.patch( - 'pygitgo.commands.jump.get_current_branch', - return_value=main_branch + fake_run.assert_any_call( + ['git', 'checkout', "original_branch"], + loading_msg="Jumping you back to the original branch 'original_branch'..." + ) + fake_run.assert_any_call( + ["git", "branch", "-D", "ghost-branch"], + allow_fail=True, + loading_msg="Removing the empty branch 'ghost-branch'..." ) + fake_pop.assert_called_once() + + +def test_jump_operation_same_branch(mocker): + mocker.patch('pygitgo.commands.jump.get_current_branch', return_value='main') fake_warning = mocker.patch('pygitgo.commands.jump.warning') - assert capture_system_exit_code(lambda: jump_operation(make_args(main_branch))) == 0 + assert capture_system_exit_code(lambda: jump_operation(make_args('main'))) == 0 + fake_warning.assert_called_with("\nYou are already on branch 'main'.\n") - fake_warning.assert_called_with(f"\nYou are already on branch '{main_branch}'.\n") def test_jump_operation_not_valid_repo(mocker): - mocker.patch('pygitgo.commands.jump.get_current_branch',return_value='master') - fake_warning = mocker.patch('pygitgo.commands.jump.warning') - - fake_run = mocker.patch( - 'pygitgo.commands.jump.run_command', - return_value=subprocess.CalledProcessError(1, 'git') + mocker.patch('pygitgo.commands.jump.get_current_branch', return_value='master') + mocker.patch('pygitgo.commands.jump.warning') + mocker.patch( + 'pygitgo.commands.jump.run_command', + side_effect=GitCommandError(['git', 'status'], stderr='not a repo', returncode=128) ) - + assert capture_system_exit_code(lambda: jump_operation(make_args('main'))) == 1 - fake_run.assert_any_call(['git', 'status', '--porcelain'], allow_fail=True, loading_msg="Checking for uncommitted changes...") def test_jump_operation_has_changes_exit(mocker): - mocker.patch('pygitgo.commands.jump.get_current_branch',return_value='master') - + mocker.patch('pygitgo.commands.jump.get_current_branch', return_value='master') fake_warning = mocker.patch('pygitgo.commands.jump.warning') - mocker.patch('builtins.input',side_effect=['n']) - - fake_run = mocker.patch( - 'pygitgo.commands.jump.run_command', - side_effect=['some-changes'] - ) + mocker.patch('builtins.input', side_effect=['n']) + mocker.patch('pygitgo.commands.jump.run_command', return_value='M file.txt') assert capture_system_exit_code(lambda: jump_operation(make_args('main'))) == 0 - fake_warning.assert_any_call("\nYou cannot switch branches with unsaved changes. Jump canceled.\n") - fake_run.assert_any_call(['git', 'status', '--porcelain'], allow_fail=True, loading_msg="Checking for uncommitted changes...") + + +def test_jump_operation_no_changes(mocker): + mocker.patch('pygitgo.commands.jump.get_current_branch', return_value='master') + mocker.patch('pygitgo.commands.jump.get_main_branch', return_value='main') + mocker.patch('pygitgo.commands.jump.is_branch_exist', return_value=True) + fake_success = mocker.patch('pygitgo.commands.jump.success') + mocker.patch('pygitgo.commands.jump.run_command', side_effect=['', 'ok', 'ok']) + + assert capture_system_exit_code(lambda: jump_operation(make_args('feature'))) == 0 + + fake_success.assert_called_with("\nSuccess! You are now on 'feature'.\n") + def test_jump_operation_save_changes_error(mocker): - mocker.patch('pygitgo.commands.jump.get_current_branch',return_value='master') + mocker.patch('pygitgo.commands.jump.get_current_branch', return_value='master') fake_warning = mocker.patch('pygitgo.commands.jump.warning') mocker.patch('builtins.input', return_value='y') - fake_run = mocker.patch( - 'pygitgo.commands.jump.run_command', - side_effect=lambda *args, **kwargs: ( - 'ok' if args[0] != ["git", "stash", "push", "-u", "-m", "GitGo Jump Auto-Stash"] else - subprocess.CalledProcessError(1, 'git') - ) + + mocker.patch('pygitgo.commands.jump.run_command', return_value='M file.txt') + mocker.patch( + 'pygitgo.commands.jump.git_stash_push', + return_value=False ) assert capture_system_exit_code(lambda: jump_operation(make_args('main'))) == 1 - - fake_warning.assert_called_with("\nFailed to save your changes. Please resolve any issues and try again.") - fake_run.assert_any_call(['git', 'status', '--porcelain'], allow_fail=True, loading_msg="Checking for uncommitted changes...") - fake_run.assert_any_call(["git", "stash", "push", "-u", "-m", "GitGo Jump Auto-Stash"], allow_fail=True, loading_msg="Saving your changes before jumping...") - -def test_jump_operation_no_changes(mocker): - mocker.patch('pygitgo.commands.jump.get_current_branch',return_value='master') - mocker.patch('pygitgo.commands.jump.get_main_branch',return_value='main') - mocker.patch('pygitgo.commands.jump.is_branch_exist',return_value=True) - fake_success = mocker.patch('pygitgo.commands.jump.success') - mocker.patch('builtins.input', return_value='y') - fake_run = mocker.patch( - 'pygitgo.commands.jump.run_command', - side_effect=[ - '', 'ok', 'ok' - ] + fake_warning.assert_called_with( + "\nFailed to save your changes. Please resolve any issues and try again." ) - target_branch = 'feature' - - assert capture_system_exit_code(lambda: jump_operation(make_args(target_branch))) == 0 - fake_success.assert_called_with(f"\nSuccess! You are now on '{target_branch}'.\n") - fake_run.assert_any_call(['git', 'status', '--porcelain'], allow_fail=True, loading_msg="Checking for uncommitted changes...") - fake_run.assert_any_call(['git', 'checkout', target_branch], loading_msg=f"Moving you to branch '{target_branch}'...") - fake_run.assert_any_call(['git', 'pull', 'origin', 'main'], allow_fail=True, loading_msg=f"Downloading the latest updates from 'main'...") def test_jump_operation_branch_not_exist_cancel_operation(mocker): - mocker.patch('pygitgo.commands.jump.get_current_branch',return_value='master') - mocker.patch('pygitgo.commands.jump.get_main_branch',return_value='main') - mocker.patch('pygitgo.commands.jump.is_branch_exist',return_value=False) - mocker.patch( - 'builtins.input', - side_effect=['y', 'n'] - ) + mocker.patch('pygitgo.commands.jump.get_current_branch', return_value='master') + mocker.patch('pygitgo.commands.jump.get_main_branch', return_value='main') + mocker.patch('pygitgo.commands.jump.is_branch_exist', return_value=False) + mocker.patch('builtins.input', side_effect=['y', 'n']) - fake_warning = mocker.patch('pygitgo.commands.jump.warning') fake_info = mocker.patch('pygitgo.commands.jump.info') + mocker.patch('pygitgo.commands.jump.run_command', return_value='M file.txt') + mocker.patch('pygitgo.commands.jump.git_stash_push', return_value=True) + fake_pop = mocker.patch('pygitgo.commands.jump.git_stash_pop', return_value=True) - fake_run = mocker.patch( - 'pygitgo.commands.jump.run_command', - side_effect=[ - 'ok', 'ok', 'ok', 'ok' - ] - ) - - target_branch = 'feature' - - assert capture_system_exit_code(lambda: jump_operation(make_args(target_branch))) == 0 + assert capture_system_exit_code(lambda: jump_operation(make_args('feature'))) == 0 fake_info.assert_any_call('\nYour changes have been saved. Jumping to the new branch...') fake_info.assert_any_call("Exiting without jumping...\n") - fake_run.assert_any_call(['git', 'status', '--porcelain'], allow_fail=True, loading_msg="Checking for uncommitted changes...") - fake_run.assert_any_call(["git", "stash", "push", "-u", "-m", "GitGo Jump Auto-Stash"], allow_fail=True, loading_msg="Saving your changes before jumping...") - fake_run.assert_any_call(["git", "stash", "pop"], loading_msg="Putting your unsaved changes back...") + fake_pop.assert_called_once() -def test_jump_operation_branch_not_exist_create_branch(mocker): - mocker.patch('pygitgo.commands.jump.get_current_branch',return_value='master') - mocker.patch('pygitgo.commands.jump.get_main_branch',return_value='main') - mocker.patch('pygitgo.commands.jump.is_branch_exist',return_value=False) - mocker.patch('pygitgo.commands.jump.git_new_branch',return_value=None) - mocker.patch( - 'builtins.input', - side_effect=['y', 'y'] - ) +def test_jump_operation_branch_not_exist_create_branch(mocker): + mocker.patch('pygitgo.commands.jump.get_current_branch', return_value='master') + mocker.patch('pygitgo.commands.jump.get_main_branch', return_value='main') + mocker.patch('pygitgo.commands.jump.is_branch_exist', return_value=False) + mocker.patch('pygitgo.commands.jump.git_new_branch', return_value=None) + mocker.patch('builtins.input', side_effect=['y', 'y']) fake_success = mocker.patch('pygitgo.commands.jump.success') - fake_warning = mocker.patch('pygitgo.commands.jump.warning') fake_info = mocker.patch('pygitgo.commands.jump.info') - fake_run = mocker.patch( + mocker.patch( 'pygitgo.commands.jump.run_command', - side_effect=[ - 'ok', 'ok', 'ok', 'ok', 'ok' - ] + side_effect=lambda *a, **kw: 'M file.txt' if a[0] == ['git', 'status', '--porcelain'] else 'ok' ) + mocker.patch('pygitgo.commands.jump.git_stash_push', return_value=True) + fake_apply = mocker.patch('pygitgo.commands.jump.git_stash_apply', return_value=True) + fake_drop = mocker.patch('pygitgo.commands.jump.git_stash_drop', return_value=True) - target_branch = 'feature' - - assert capture_system_exit_code(lambda: jump_operation(make_args(target_branch))) == 0 + assert capture_system_exit_code(lambda: jump_operation(make_args('feature'))) == 0 fake_info.assert_called_with('\nYour changes have been saved. Jumping to the new branch...') - fake_success.assert_any_call(f"\nSuccess! You are now on '{target_branch}'.") + fake_success.assert_any_call("\nSuccess! You are now on 'feature'.") fake_success.assert_any_call("Your unsaved code was moved here safely!\n") - fake_run.assert_any_call(['git', 'status', '--porcelain'], allow_fail=True, loading_msg="Checking for uncommitted changes...") - fake_run.assert_any_call(['git', 'pull', 'origin', 'main'], allow_fail=True, loading_msg=f"Downloading the latest updates from 'main'...") - fake_run.assert_any_call(["git", "stash", "push", "-u", "-m", "GitGo Jump Auto-Stash"], allow_fail=True, loading_msg="Saving your changes before jumping...") - fake_run.assert_any_call(['git', 'stash', 'apply'], allow_fail=True, loading_msg="Unpacking your unsaved changes...") - fake_run.assert_any_call(["git", "stash", "drop"], allow_fail=True, loading_msg="Cleaning up the temporary stash...") + fake_apply.assert_called_once() + fake_drop.assert_called_once() + def test_jump_operation_sync_fail_cancel(mocker): - mocker.patch('pygitgo.commands.jump.get_current_branch',return_value='master') - mocker.patch('pygitgo.commands.jump.get_main_branch',return_value='main') - mocker.patch('pygitgo.commands.jump.is_branch_exist',return_value=True) - mocker.patch('pygitgo.commands.jump.undo_jump_operation',return_value=None) - mocker.patch( - 'builtins.input', - side_effect=['y', 'n'] - ) + mocker.patch('pygitgo.commands.jump.get_current_branch', return_value='master') + mocker.patch('pygitgo.commands.jump.get_main_branch', return_value='main') + mocker.patch('pygitgo.commands.jump.is_branch_exist', return_value=True) + mocker.patch('pygitgo.commands.jump.undo_jump_operation', return_value=None) + mocker.patch('builtins.input', side_effect=['n']) # "stay?" → no fake_warning = mocker.patch('pygitgo.commands.jump.warning') - mocker.patch( - 'pygitgo.commands.jump.run_command', - side_effect=lambda *args, **kwargs: ( - subprocess.CalledProcessError(1, 'git') if args[0] == ['git', 'pull', 'origin', 'main'] else 'ok' - ) - ) + + def _run(*args, **kwargs): + cmd = args[0] + if cmd == ['git', 'status', '--porcelain']: + return '' + if cmd[1] == 'pull': + return GitCommandError(cmd, stderr='failed', returncode=1) + return 'ok' + + mocker.patch('pygitgo.commands.jump.run_command', side_effect=_run) assert capture_system_exit_code(lambda: jump_operation(make_args('feature'))) == 1 + fake_warning.assert_any_call( + "\nFailed to pull updates from 'main'. Make sure you have internet or the remote branch exists." + ) - fake_warning.assert_any_call("\nFailed to pull updates from 'main'. Make sure you have internet or the remote branch exists.") def test_jump_operation_sync_fail_stay(mocker): - mocker.patch('pygitgo.commands.jump.get_current_branch',return_value='master') - mocker.patch('pygitgo.commands.jump.get_main_branch',return_value='main') - mocker.patch('pygitgo.commands.jump.is_branch_exist',return_value=True) - mocker.patch( - 'builtins.input', - side_effect=['y', 'y'] - ) + mocker.patch('pygitgo.commands.jump.get_current_branch', return_value='master') + mocker.patch('pygitgo.commands.jump.get_main_branch', return_value='main') + mocker.patch('pygitgo.commands.jump.is_branch_exist', return_value=True) + mocker.patch('builtins.input', side_effect=['y']) # "stay?" → yes fake_success = mocker.patch('pygitgo.commands.jump.success') - mocker.patch( - 'pygitgo.commands.jump.run_command', - side_effect=lambda *args, **kwargs: ( - subprocess.CalledProcessError(1, 'git') if args[0] == ['git', 'pull', 'origin', 'main'] else 'ok' - ) - ) + + def _run(*args, **kwargs): + cmd = args[0] + if cmd == ['git', 'status', '--porcelain']: + return '' + if cmd[1] == 'pull': + return GitCommandError(cmd, stderr='failed', returncode=1) + return 'ok' + + mocker.patch('pygitgo.commands.jump.run_command', side_effect=_run) assert capture_system_exit_code(lambda: jump_operation(make_args('feature'))) == 0 + fake_success.assert_any_call( + "\nOkay! You are on the new branch, but without the latest updates from 'main'." + ) + - fake_success.assert_any_call("\nOkay! You are on the new branch, but without the latest updates from 'main'.") def test_jump_operation_merge_conflict_cancel(mocker): - mocker.patch('pygitgo.commands.jump.get_current_branch',return_value='master') - mocker.patch('pygitgo.commands.jump.get_main_branch',return_value='main') - mocker.patch('pygitgo.commands.jump.is_branch_exist',return_value=True) - mocker.patch('pygitgo.commands.jump.undo_jump_operation',return_value=None) - mocker.patch( - 'builtins.input', - side_effect=['y', 'n'] - ) + mocker.patch('pygitgo.commands.jump.get_current_branch', return_value='master') + mocker.patch('pygitgo.commands.jump.get_main_branch', return_value='main') + mocker.patch('pygitgo.commands.jump.is_branch_exist', return_value=True) + mocker.patch('pygitgo.commands.jump.undo_jump_operation', return_value=None) + mocker.patch('builtins.input', side_effect=['y', 'n']) fake_error = mocker.patch('pygitgo.commands.jump.error') + mocker.patch( 'pygitgo.commands.jump.run_command', - side_effect=lambda *args, **kwargs: ( - subprocess.CalledProcessError(1, 'git') if args[0] == ['git', 'stash', 'apply'] else 'ok' - ) + side_effect=lambda *a, **kw: 'M file.txt' if a[0] == ['git', 'status', '--porcelain'] else 'ok' + ) + mocker.patch('pygitgo.commands.jump.git_stash_push', return_value=True) + mocker.patch( + 'pygitgo.commands.jump.git_stash_apply', + return_value=False ) assert capture_system_exit_code(lambda: jump_operation(make_args('feature'))) == 0 - fake_error.assert_any_call("\nSTOP! There is a 'Merge Conflict'.") + def test_jump_operation_merge_conflict_stay(mocker): - mocker.patch('pygitgo.commands.jump.get_current_branch',return_value='master') - mocker.patch('pygitgo.commands.jump.get_main_branch',return_value='main') - mocker.patch('pygitgo.commands.jump.is_branch_exist',return_value=True) - mocker.patch( - 'builtins.input', - side_effect=['y', 'y'] - ) + mocker.patch('pygitgo.commands.jump.get_current_branch', return_value='master') + mocker.patch('pygitgo.commands.jump.get_main_branch', return_value='main') + mocker.patch('pygitgo.commands.jump.is_branch_exist', return_value=True) + mocker.patch('builtins.input', side_effect=['y', 'y']) fake_success = mocker.patch('pygitgo.commands.jump.success') fake_warning = mocker.patch('pygitgo.commands.jump.warning') fake_info = mocker.patch('pygitgo.commands.jump.info') - fake_run = mocker.patch( + + mocker.patch( 'pygitgo.commands.jump.run_command', - side_effect=lambda *args, **kwargs: ( - subprocess.CalledProcessError(1, 'git') if args[0] == ['git', 'stash', 'apply'] else 'ok' - ) + side_effect=lambda *a, **kw: 'M file.txt' if a[0] == ['git', 'status', '--porcelain'] else 'ok' ) + mocker.patch('pygitgo.commands.jump.git_stash_push', return_value=True) + mocker.patch( + 'pygitgo.commands.jump.git_stash_apply', + return_value=False + ) + fake_drop = mocker.patch('pygitgo.commands.jump.git_stash_drop', return_value=True) assert capture_system_exit_code(lambda: jump_operation(make_args('feature'))) == 0 fake_success.assert_any_call("\nOkay! You are on the new branch with your code.") fake_warning.assert_any_call("Please open your code editor RIGHT NOW to fix the conflicts!") fake_info.assert_any_call("Your stash backup is still saved. Run 'gitgo state list' to see it.\n") - - with pytest.raises(AssertionError): - fake_run.assert_any_call(["git", "stash", "drop"], allow_fail=True, loading_msg="Cleaning up the temporary stash...") + fake_drop.assert_not_called() diff --git a/tests/test_state.py b/tests/test_state.py index 5f2cace..670ea71 100644 --- a/tests/test_state.py +++ b/tests/test_state.py @@ -1,122 +1,100 @@ from pygitgo.commands.state import ( - delete_state, save_state, load_state, validate_state_id, + delete_state, save_state, load_state, validate_state_id, all_save_state ) -from unittest.mock import call import pytest -@pytest.mark.parametrize('state_id',[ - '1', '3', '11', '00002' -]) +# --------------------------------------------------------------------------- +# validate_state_id +# --------------------------------------------------------------------------- + +@pytest.mark.parametrize('state_id', ['1', '3', '11', '00002']) def test_validate_state_id(state_id, mocker): fake_error = mocker.patch('pygitgo.commands.state.error') - - save_states = [1] * 12 - result = validate_state_id(state_id, save_states) - assert result == True - + result = validate_state_id(state_id, [1] * 12) + assert result is True fake_error.assert_not_called() -@pytest.mark.parametrize('state_id',[ - '-1', '-3', '-11', '-00002' -]) -def test_validate_state_id_negative(state_id, mocker): - save_states = [1] * 12 +@pytest.mark.parametrize('state_id', ['-1', '-3', '-11', '-00002']) +def test_validate_state_id_negative(state_id, mocker): fake_error = mocker.patch('pygitgo.commands.state.error') - - result = validate_state_id(state_id, save_states) - assert result == False - + result = validate_state_id(state_id, [1] * 12) + assert result is False fake_error.assert_called_with("\nState ID cannot be '0' or negative. Please enter a valid state ID.\n") -@pytest.mark.parametrize('state_id',[ - '4', '10', '15', '0000020' -]) -def test_validate_state_id_out_scope(state_id, mocker): - save_states = [1] * 3 +@pytest.mark.parametrize('state_id', ['4', '10', '15', '0000020']) +def test_validate_state_id_out_scope(state_id, mocker): fake_error = mocker.patch('pygitgo.commands.state.error') - - result = validate_state_id(state_id, save_states) - assert result == False - + result = validate_state_id(state_id, [1] * 3) + assert result is False fake_error.assert_called_with("\nState ID out of range. Please enter a valid state ID.\n") +# --------------------------------------------------------------------------- +# all_save_state +# --------------------------------------------------------------------------- + def test_all_save_state_no_output(mocker): - fake_run = mocker.patch("pygitgo.commands.state.run_command", return_value="") - fake_info = mocker.patch("pygitgo.commands.state.info") + mocker.patch("pygitgo.commands.state.git_stash_list", return_value="") + mocker.patch("pygitgo.commands.state.info") - all_save_state() + result = all_save_state() + + assert result == [] - fake_info.assert_called_once_with("\nNo saved states found.\n") - fake_run.assert_called_once_with([ - "git", "stash", "list", - "--date=format:%Y-%m-%d %H:%M:%S", - "--pretty=%gd||%cd||%s" - ]) def test_all_save_state_with_output(mocker): - output = "stash@{0}||2023-10-27 10:00:00||Test stash\nstash@{1}||2023-10-27 10:05:00||Another stash" - fake_run = mocker.patch("pygitgo.commands.state.run_command", return_value=output) + output = ( + "stash@{0}||2023-10-27 10:00:00||Test stash\n" + "stash@{1}||2023-10-27 10:05:00||Another stash" + ) + mocker.patch("pygitgo.commands.state.git_stash_list", return_value=output) result = all_save_state() assert len(result) == 2 assert result[0] == { - "id": 1, - "ref": "stash@{0}", - "date": "2023-10-27 10:00:00", - "message": "Test stash" + "id": 1, "ref": "stash@{0}", + "date": "2023-10-27 10:00:00", "message": "Test stash" } assert result[1] == { - "id": 2, - "ref": "stash@{1}", - "date": "2023-10-27 10:05:00", - "message": "Another stash" + "id": 2, "ref": "stash@{1}", + "date": "2023-10-27 10:05:00", "message": "Another stash" } - fake_run.assert_called_once_with([ - "git", "stash", "list", - "--date=format:%Y-%m-%d %H:%M:%S", - "--pretty=%gd||%cd||%s" - ]) + def test_all_save_state_malformed_line(mocker): output = "malformed_line_here\nstash@{1}||2023-10-27 10:05:00||Another stash" - fake_run = mocker.patch("pygitgo.commands.state.run_command", return_value=output) + mocker.patch("pygitgo.commands.state.git_stash_list", return_value=output) fake_warning = mocker.patch("pygitgo.commands.state.warning") result = all_save_state() assert len(result) == 1 - assert result[0] == { - "id": 2, - "ref": "stash@{1}", - "date": "2023-10-27 10:05:00", - "message": "Another stash" - } + assert result[0]["message"] == "Another stash" fake_warning.assert_called_once_with("Skipping malformed line: malformed_line_here") - fake_run.assert_called_once_with([ - "git", "stash", "list", - "--date=format:%Y-%m-%d %H:%M:%S", - "--pretty=%gd||%cd||%s" - ]) +# --------------------------------------------------------------------------- +# load_state +# --------------------------------------------------------------------------- + def test_load_state_specific_id(mocker): save_states = [{"id": 1, "ref": "stash@{0}", "date": "date", "message": "msg"}] mocker.patch("pygitgo.commands.state.all_save_state", return_value=save_states) mocker.patch("pygitgo.commands.state.validate_state_id", return_value=True) - fake_run = mocker.patch("pygitgo.commands.state.run_command") + fake_apply = mocker.patch("pygitgo.commands.state.git_stash_apply", return_value=True) fake_success = mocker.patch("pygitgo.commands.state.success") load_state("1") - fake_run.assert_called_once_with(["git", "stash", "apply", "0"]) + fake_apply.assert_called_once_with(stash_id="0") fake_success.assert_called_once_with("\nState 'msg' loaded successfully.\n") + def test_load_state_invalid_id(mocker): save_states = [{"id": 1, "ref": "stash@{0}", "date": "date", "message": "msg"}] mocker.patch("pygitgo.commands.state.all_save_state", return_value=save_states) @@ -126,91 +104,122 @@ def test_load_state_invalid_id(mocker): with pytest.raises(GitGoError): load_state("100") + def test_load_state_invalid_argument(mocker): save_states = [{"id": 1, "ref": "stash@{0}", "date": "date", "message": "msg"}] mocker.patch("pygitgo.commands.state.all_save_state", return_value=save_states) - fake_error = mocker.patch("pygitgo.commands.state.error") from pygitgo.exceptions import GitGoError with pytest.raises(GitGoError): load_state("invalid_arg") + def test_load_state_no_args(mocker): - save_states = [{"id": 1, "ref": "stash@{0}", "date": "date", "message": "msg"}, - {"id": 2, "ref": "stash@{1}", "date": "date2", "message": "msg2"}] + save_states = [ + {"id": 1, "ref": "stash@{0}", "date": "date", "message": "msg"}, + {"id": 2, "ref": "stash@{1}", "date": "date2", "message": "msg2"}, + ] mocker.patch("pygitgo.commands.state.all_save_state", return_value=save_states) mocker.patch("pygitgo.commands.state.ask_state_id", return_value="2") - fake_run = mocker.patch("pygitgo.commands.state.run_command") + fake_apply = mocker.patch("pygitgo.commands.state.git_stash_apply", return_value=True) fake_success = mocker.patch("pygitgo.commands.state.success") load_state() - fake_run.assert_called_once_with(["git", "stash", "apply", "1"]) + fake_apply.assert_called_once_with(stash_id="1") fake_success.assert_called_once_with("\nState 'msg2' loaded successfully.\n") +# --------------------------------------------------------------------------- +# save_state +# --------------------------------------------------------------------------- + def test_save_state_no_args(mocker): - fake_run = mocker.patch("pygitgo.commands.state.run_command", return_value="Saved working directory") + mocker.patch("pygitgo.commands.state.run_command", return_value="M file") + fake_push = mocker.patch( + "pygitgo.commands.state.git_stash_push", + return_value=True + ) fake_success = mocker.patch("pygitgo.commands.state.success") save_state() - fake_run.assert_called_once_with(["git", "stash", "push", "-m", "Auto-Save"], allow_fail=True) + fake_push.assert_called_once_with(label="Auto-Save") fake_success.assert_called_once_with("\nState 'Auto-Save' saved successfully.\n") + def test_save_state_with_name(mocker): - fake_run = mocker.patch("pygitgo.commands.state.run_command", return_value="Saved working directory") + mocker.patch("pygitgo.commands.state.run_command", return_value="M file") + fake_push = mocker.patch( + "pygitgo.commands.state.git_stash_push", + return_value=True + ) fake_success = mocker.patch("pygitgo.commands.state.success") save_state("My-State") - fake_run.assert_called_once_with(["git", "stash", "push", "-m", "My-State"], allow_fail=True) + fake_push.assert_called_once_with(label="My-State") fake_success.assert_called_once_with("\nState 'My-State' saved successfully.\n") +# --------------------------------------------------------------------------- +# delete_state +# --------------------------------------------------------------------------- + def test_delete_state_all_confirm(mocker): mocker.patch("builtins.input", return_value="y") - fake_run = mocker.patch("pygitgo.commands.state.run_command") + mocker.patch("pygitgo.commands.state.all_save_state", return_value=[{"id": 1}]) + fake_clear = mocker.patch("pygitgo.commands.state.git_stash_clear", return_value=True) fake_success = mocker.patch("pygitgo.commands.state.success") delete_state("-a") - fake_run.assert_called_once_with(["git", "stash", "clear"]) + fake_clear.assert_called_once() fake_success.assert_called_once_with("\nAll saved states deleted successfully.\n") + def test_delete_state_all_cancel(mocker): mocker.patch("builtins.input", return_value="n") + mocker.patch("pygitgo.commands.state.all_save_state", return_value=[{"id": 1}]) fake_warning = mocker.patch("pygitgo.commands.state.warning") delete_state("-a") fake_warning.assert_called_once_with("\nDelete operation cancelled by user.\n") + def test_delete_state_invalid_id(mocker): - fake_error = mocker.patch("pygitgo.commands.state.error") - + mocker.patch("pygitgo.commands.state.all_save_state", return_value=[{"id": 1}]) + from pygitgo.exceptions import GitGoError with pytest.raises(GitGoError): delete_state("abc") + def test_delete_state_specific_id(mocker): + save_states = [{"id": 1, "ref": "stash@{0}", "date": "date", "message": "msg"}] mocker.patch("pygitgo.commands.state.validate_state_id", return_value=True) - mocker.patch("pygitgo.commands.state.all_save_state", return_value=[]) - fake_run = mocker.patch("pygitgo.commands.state.run_command") + mocker.patch("pygitgo.commands.state.all_save_state", return_value=save_states) + fake_drop = mocker.patch("pygitgo.commands.state.git_stash_drop", return_value=True) fake_success = mocker.patch("pygitgo.commands.state.success") delete_state("1") - fake_run.assert_called_once_with(["git", "stash", "drop", "0"]) + fake_drop.assert_called_once_with(stash_id="0") fake_success.assert_called_once_with("\nState with ID '1' deleted successfully.\n") + def test_delete_state_no_args(mocker): - mocker.patch("pygitgo.commands.state.all_save_state", return_value=[]) + save_states = [ + {"id": 1, "ref": "stash@{0}", "date": "date", "message": "msg"}, + {"id": 2, "ref": "stash@{1}", "date": "date2", "message": "msg2"}, + ] + mocker.patch("pygitgo.commands.state.all_save_state", return_value=save_states) mocker.patch("pygitgo.commands.state.ask_state_id", return_value="2") - fake_run = mocker.patch("pygitgo.commands.state.run_command") + fake_drop = mocker.patch("pygitgo.commands.state.git_stash_drop", return_value=True) fake_success = mocker.patch("pygitgo.commands.state.success") delete_state() - fake_run.assert_called_once_with(["git", "stash", "drop", "1"]) + fake_drop.assert_called_once_with(stash_id="1") fake_success.assert_called_once_with("\nState with ID '2' deleted successfully.\n")