diff --git a/CHANGELOG.md b/CHANGELOG.md index 4e68a13..a90ed2f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,8 +9,13 @@ Versions follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] ### Added +- **Expanded Undo:** Added `gitgo undo link` to cleanly remove an initialized remote and undo the initial commit, and `gitgo undo push` to force-revert the last remote push. - **Dynamic Branch Initialization:** `gitgo link` and core init routines now dynamically resolve the default branch name. It respects your global `init.defaultBranch` setting, falls back to the `gitgo.default-branch` config, and defaults to `main` if neither is found. +### Fixed +- Fixed misleading post-action hints in `gitgo link` and `gitgo push` that incorrectly suggested `undo commit` instead of a full link/push revert. +- Fixed `gitgo pull` autostash recovery hint hardcoding the stash index to 1. + --- ## [1.7.0] - 2026-06-02 diff --git a/README.md b/README.md index 4a6e1f7..1841a8d 100644 --- a/README.md +++ b/README.md @@ -63,7 +63,7 @@ gitgo link https://github.com/username/repo.git "init" ## Features - **Single commands for linking, pushing, and stashing.** No more chaining five commands together. -- **Undo:** Roll back commits, unstage files, or discard local changes. The subcommands say what they do: `undo commit`, `undo add`, `undo changes`. +- **Undo:** Roll back commits, unstage files, discard local changes, or revert pushes. The subcommands say what they do: `undo commit`, `undo add`, `undo changes`, `undo link`, `undo push`. - **Branch switching with `jump`:** Stashes your uncommitted work, moves to the target branch, syncs with main, and pops the stash. If a merge conflict occurs, the Try-and-Revert engine offers to roll the whole operation back. - **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. @@ -170,6 +170,8 @@ Undo recent mistakes with commands named for what they undo. gitgo undo commit # Undo the last commit (files stay staged) gitgo undo add # Unstage files gitgo undo changes # DANGER: permanently discard all uncommitted edits +gitgo undo link # Remove remote and undo initial commit +gitgo undo push # DANGER: Revert last push with a force-push ``` ### 6. Save Your Work-in-Progress @@ -245,6 +247,8 @@ Undo recent actions with subcommands named for what they undo. gitgo undo commit # Undo the last commit without losing files gitgo undo add # Unstage files gitgo undo changes # Permanently discard all new files and uncommitted edits +gitgo undo link # Remove the remote and undo the initial commit +gitgo undo push # DANGER: Revert the last push with a force-push ``` ### `gitgo state` diff --git a/pyproject.toml b/pyproject.toml index 90225b3..e5ec1a5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "pygitgo" -version = "1.7.1" +version = "1.7.2" 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/commands/link.py b/src/pygitgo/commands/link.py index b37b5d1..8f86280 100644 --- a/src/pygitgo/commands/link.py +++ b/src/pygitgo/commands/link.py @@ -101,7 +101,7 @@ def link_operation(args): print_banner("REPOSITORY INITIALIZED AND DEPLOYED.") print() - info("Run 'gitgo undo commit' to revert this push if the deploy was unintended.") + info("Run 'gitgo undo link' to remove the remote and undo the initial commit.") except KeyboardInterrupt: print() diff --git a/src/pygitgo/commands/pull.py b/src/pygitgo/commands/pull.py index 10bdc68..e5ebc68 100644 --- a/src/pygitgo/commands/pull.py +++ b/src/pygitgo/commands/pull.py @@ -27,7 +27,7 @@ def _pull_interrupt_cleanup(): stash_list = run_command(["git", "stash", "list"]) if stash_list and "autostash" in stash_list.lower(): info("An autostash entry was found. Your local changes are safe.") - info("Run 'gitgo state list' to view it, or 'gitgo state load 1' to restore it.") + info("Run 'gitgo state list' to find it, then 'gitgo state load ' to restore it.") except GitCommandError: pass diff --git a/src/pygitgo/commands/push.py b/src/pygitgo/commands/push.py index 3adc83d..5da9ad9 100644 --- a/src/pygitgo/commands/push.py +++ b/src/pygitgo/commands/push.py @@ -26,7 +26,7 @@ def _push_interrupt_cleanup(original_branch, original_head, created_branch): if commit_was_made: info(f"Commit was saved locally on '{current_branch}' but was not pushed.") info("Run 'gitgo push' to retry the push.") - info("Run 'gitgo undo commit' to undo the commit instead.") + info("Run 'gitgo undo commit' to undo the local commit instead.") return try: @@ -141,10 +141,12 @@ def push_operation(args): print_banner("MISSION COMPLETE. ALL TARGETS COMMITTED AND PUSHED.") + print() if auto_switched_from: - print() info(f"Switched from '{auto_switched_from}' to '{branch}' automatically.") - info(f"Run 'gitgo undo commit' then 'gitgo jump {auto_switched_from}' to revert this push and return.") + info(f"Run 'gitgo undo push' to revert the push, then 'gitgo jump {auto_switched_from}' to return.") + else: + info("Run 'gitgo undo push' to revert this push if it was unintended.") except KeyboardInterrupt: print() diff --git a/src/pygitgo/commands/undo.py b/src/pygitgo/commands/undo.py index a3e7547..057e229 100644 --- a/src/pygitgo/commands/undo.py +++ b/src/pygitgo/commands/undo.py @@ -1,3 +1,4 @@ +from pygitgo.commands.git_branch import get_current_branch from pygitgo.utils.colors import success, warning, info, error from pygitgo.exceptions import GitCommandError, GitGoError from pygitgo.utils.executor import run_command @@ -48,6 +49,54 @@ def undo_changes(): sys.exit(130) +def undo_link(): + try: + run_command(["git", "remote", "remove", "origin"]) + success("Remote 'origin' removed.") + except GitCommandError as e: + raise GitGoError( + f"Could not remove remote 'origin'. Is one set? Details: {e}" + ) + + try: + run_command(["git", "reset", "--soft", "HEAD~"]) + success("Initial commit undone. Files are back to staged, ready to re-link.") + except GitCommandError: + info("Remote removed. Could not undo the commit (none or multiple found).") + info("Run 'gitgo undo commit' separately if needed.") + + +def undo_push(): + try: + branch = get_current_branch() + except GitCommandError as e: + raise GitGoError(f"Could not determine the current branch: {e}") + + error("DANGER: This force-pushes to the remote. Other collaborators will be affected.") + warning("Only use this if no one else has pulled the commit you are reverting.") + confirm = input("Are you sure you want to undo the last push? (y/n): ") + if confirm.lower() != "y": + info("Canceled. Remote is unchanged.") + return + + try: + run_command(["git", "reset", "--soft", "HEAD~"], loading_msg="Reverting last commit locally...") + except GitCommandError as e: + raise GitGoError(f"Undo failed — no previous commit to revert. Details: {e}") + + try: + run_command( + ["git", "push", "--force", "origin", branch], + loading_msg=f"Force-pushing reverted state to '{branch}'..." + ) + success(f"Last push reverted. Remote '{branch}' is back to the previous commit.") + info("Your files are still staged locally. Edit and push again when ready.") + except GitCommandError as e: + warning("Local commit was undone, but the force-push failed.") + warning(f"Run manually: git push --force origin {branch}") + raise GitGoError(f"Force-push failed: {e}") + + def undo_operation(args): action = args.action @@ -57,5 +106,9 @@ def undo_operation(args): undo_add() elif action == "changes": undo_changes() + elif action == "link": + undo_link() + elif action == "push": + undo_push() else: raise GitGoError(f"Unknown undo operation: {action}") \ No newline at end of file diff --git a/src/pygitgo/main.py b/src/pygitgo/main.py index 5df88f2..dba7579 100644 --- a/src/pygitgo/main.py +++ b/src/pygitgo/main.py @@ -123,14 +123,16 @@ def main(): "Examples:\n" " gitgo undo commit Undo your last commit (your files are safe)\n" " gitgo undo add Undo 'git add' (files are no longer ready to commit)\n" - " gitgo undo changes DANGER: Throw away all new changes and start fresh" + " gitgo undo changes DANGER: Throw away all new changes and start fresh\n" + " gitgo undo link Remove the remote and undo the initial commit\n" + " gitgo undo push DANGER: Revert the last push with a force-push" ), formatter_class=argparse.RawDescriptionHelpFormatter ) undo_parser.add_argument( "action", - choices=["commit", "add", "changes"], - help="What to undo: 'commit', 'add', or 'changes'" + choices=["commit", "add", "changes", "link", "push"], + help="What to undo: 'commit', 'add', 'changes', 'link', or 'push'" ) pull_parser = subparsers.add_parser("pull", diff --git a/tests/test_push.py b/tests/test_push.py index 3bcbab4..f3765c5 100644 --- a/tests/test_push.py +++ b/tests/test_push.py @@ -39,7 +39,7 @@ def test_push_wrong_branch_auto_switch(mocker): fake_info.assert_any_call("Switching to target branch 'feature-branch'...") fake_info.assert_any_call("Switched from 'main' to 'feature-branch' automatically.") - fake_info.assert_any_call("Run 'gitgo undo commit' then 'gitgo jump main' to revert this push and return.") + fake_info.assert_any_call("Run 'gitgo undo push' to revert the push, then 'gitgo jump main' to return.") jump_args = fake_jump.call_args[0][0] assert jump_args.branch == "feature-branch" fake_commit.assert_called_once_with("Init commit") diff --git a/tests/test_undo.py b/tests/test_undo.py index 275f736..e668865 100644 --- a/tests/test_undo.py +++ b/tests/test_undo.py @@ -1,9 +1,10 @@ import pytest -from unittest.mock import patch, MagicMock -from pygitgo.commands.undo import undo_commit, undo_add, undo_changes, undo_operation +from unittest.mock import patch, MagicMock, call +from pygitgo.commands.undo import undo_commit, undo_add, undo_changes, undo_link, undo_push, undo_operation from pygitgo.exceptions import GitGoError, GitCommandError from argparse import Namespace + @patch("pygitgo.commands.undo.run_command") @patch("pygitgo.commands.undo.success") def test_undo_commit_success(mock_success, mock_run_command): @@ -58,6 +59,78 @@ def test_undo_changes_abort(mock_input, mock_info, mock_run_command): mock_run_command.assert_not_called() +# --- undo_link tests --- + +@patch("pygitgo.commands.undo.run_command") +@patch("pygitgo.commands.undo.success") +def test_undo_link_success(mock_success, mock_run_command): + undo_link() + mock_run_command.assert_any_call(["git", "remote", "remove", "origin"]) + mock_run_command.assert_any_call(["git", "reset", "--soft", "HEAD~"]) + assert mock_success.call_count == 2 + + +@patch("pygitgo.commands.undo.run_command") +def test_undo_link_no_remote(mock_run_command): + mock_run_command.side_effect = GitCommandError(["git", "remote", "remove", "origin"]) + with pytest.raises(GitGoError, match="Could not remove remote"): + undo_link() + + +@patch("pygitgo.commands.undo.run_command") +@patch("pygitgo.commands.undo.success") +@patch("pygitgo.commands.undo.info") +def test_undo_link_no_commit_to_reset(mock_info, mock_success, mock_run_command): + # First call (remote remove) succeeds, second (reset) fails. + mock_run_command.side_effect = [None, GitCommandError(["git", "reset"])] + undo_link() + mock_success.assert_called_once_with("Remote 'origin' removed.") + assert mock_info.call_count == 2 + + +# --- undo_push tests --- + +@patch("pygitgo.commands.undo.run_command") +@patch("pygitgo.commands.undo.get_current_branch", return_value="main") +@patch("pygitgo.commands.undo.success") +@patch("pygitgo.commands.undo.input", return_value="y") +def test_undo_push_success(mock_input, mock_success, mock_branch, mock_run_command): + undo_push() + mock_run_command.assert_any_call(["git", "reset", "--soft", "HEAD~"], loading_msg="Reverting last commit locally...") + mock_run_command.assert_any_call(["git", "push", "--force", "origin", "main"], loading_msg="Force-pushing reverted state to 'main'...") + mock_success.assert_called_once() + + +@patch("pygitgo.commands.undo.run_command") +@patch("pygitgo.commands.undo.get_current_branch", return_value="main") +@patch("pygitgo.commands.undo.info") +@patch("pygitgo.commands.undo.input", return_value="n") +def test_undo_push_abort(mock_input, mock_info, mock_branch, mock_run_command): + undo_push() + mock_run_command.assert_not_called() + mock_info.assert_called_once_with("Canceled. Remote is unchanged.") + + +@patch("pygitgo.commands.undo.run_command") +@patch("pygitgo.commands.undo.get_current_branch", return_value="main") +@patch("pygitgo.commands.undo.input", return_value="y") +def test_undo_push_no_commit(mock_input, mock_branch, mock_run_command): + mock_run_command.side_effect = GitCommandError(["git", "reset"]) + with pytest.raises(GitGoError, match="Undo failed"): + undo_push() + + +@patch("pygitgo.commands.undo.run_command") +@patch("pygitgo.commands.undo.get_current_branch", return_value="main") +@patch("pygitgo.commands.undo.input", return_value="y") +def test_undo_push_force_push_fails(mock_input, mock_branch, mock_run_command): + mock_run_command.side_effect = [None, GitCommandError(["git", "push", "--force"])] + with pytest.raises(GitGoError, match="Force-push failed"): + undo_push() + + +# --- undo_operation routing tests --- + @patch("pygitgo.commands.undo.undo_commit") def test_undo_operation_commit(mock_undo_commit): args = Namespace(action="commit") @@ -79,6 +152,20 @@ def test_undo_operation_changes(mock_undo_changes): mock_undo_changes.assert_called_once() +@patch("pygitgo.commands.undo.undo_link") +def test_undo_operation_link(mock_undo_link): + args = Namespace(action="link") + undo_operation(args) + mock_undo_link.assert_called_once() + + +@patch("pygitgo.commands.undo.undo_push") +def test_undo_operation_push(mock_undo_push): + args = Namespace(action="push") + undo_operation(args) + mock_undo_push.assert_called_once() + + def test_undo_operation_invalid(): args = Namespace(action="invalid") with pytest.raises(GitGoError):