Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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`
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"}
Expand Down
2 changes: 1 addition & 1 deletion src/pygitgo/commands/link.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
2 changes: 1 addition & 1 deletion src/pygitgo/commands/pull.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 <id>' to restore it.")
except GitCommandError:
pass

Expand Down
8 changes: 5 additions & 3 deletions src/pygitgo/commands/push.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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()
Expand Down
53 changes: 53 additions & 0 deletions src/pygitgo/commands/undo.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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

Expand All @@ -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}")
8 changes: 5 additions & 3 deletions src/pygitgo/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion tests/test_push.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
91 changes: 89 additions & 2 deletions tests/test_undo.py
Original file line number Diff line number Diff line change
@@ -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):
Expand Down Expand Up @@ -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")
Expand All @@ -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):
Expand Down
Loading