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
45 changes: 45 additions & 0 deletions acp.py
Original file line number Diff line number Diff line change
Expand Up @@ -549,11 +549,56 @@ def create_pr(
# --quiet flag suppresses git output but hooks can still prompt
run_interactive(["git", "push", "--quiet", "-u", "origin", temp_branch])

# Check if there are any unstaged changes before switching
has_unstaged = not run_check(["git", "diff", "--quiet"])
stash_id = None

if has_unstaged:
# Create unique stash ID using timestamp
import time

timestamp = int(time.time())
stash_id = f"acp-stash-{timestamp}"

if verbose:
print(f"Stashing unstaged changes as '{stash_id}'...")
run(["git", "stash", "push", "-m", stash_id], quiet=True)

# Switch back to original branch
run(["git", "checkout", original_branch], quiet=True)
if verbose:
print(f"Switched back to original branch: '{original_branch}'")

# Restore stashed changes if any
if stash_id:
if verbose:
print("Restoring stashed changes...")

# Try to apply the stash
stash_pop_result = subprocess.run(
["git", "stash", "pop"], capture_output=True, text=True
)

if stash_pop_result.returncode != 0:
# Stash pop failed (likely due to conflicts)
print(
"\nWarning: Failed to automatically restore stashed changes due to conflicts.",
file=sys.stderr,
)
print(
f"Your changes are safely stored in stash: '{stash_id}'",
file=sys.stderr,
)
print(
f"To manually apply them, run: git stash apply 'stash^{{/{stash_id}}}'",
file=sys.stderr,
)
print(
f"After resolving conflicts, drop the stash with: git stash drop 'stash^{{/{stash_id}}}'",
file=sys.stderr,
)
# Note: We continue execution rather than exiting, as the PR was created successfully

if interactive:
# Build and display compare URL for manual PR creation
compare_url = build_compare_url(
Expand Down
151 changes: 133 additions & 18 deletions test_acp.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,9 @@ def test_create_pr_not_fork_ssh(
self, mock_run_check, mock_run, mock_run_interactive, mock_subprocess
):
"""Test PR creation in non-fork repo with SSH URL."""
mock_run_check.return_value = False # Has staged changes
# First call: has staged changes (False = has changes)
# Second call: no unstaged changes (True = no unstaged changes)
mock_run_check.side_effect = [False, True]

# Mock subprocess.run for upstream check (should fail - no upstream)
mock_subprocess.return_value = mock.Mock(returncode=1, stdout="")
Expand Down Expand Up @@ -101,7 +103,9 @@ def test_create_pr_not_fork_https(
self, mock_run_check, mock_run, mock_run_interactive, mock_subprocess
):
"""Test PR creation in non-fork repo with HTTPS URL."""
mock_run_check.return_value = False
# First call: has staged changes (False = has changes)
# Second call: no unstaged changes (True = no unstaged changes)
mock_run_check.side_effect = [False, True]

# No upstream remote
mock_subprocess.return_value = mock.Mock(returncode=1, stdout="")
Expand All @@ -128,7 +132,7 @@ def test_create_pr_fork_ssh(
self, mock_run_check, mock_run, mock_run_interactive, mock_subprocess
):
"""Test PR creation on a fork with SSH URLs."""
mock_run_check.return_value = False
mock_run_check.side_effect = [False, True]

# Mock upstream remote exists
mock_subprocess.return_value = mock.Mock(
Expand Down Expand Up @@ -163,7 +167,7 @@ def test_create_pr_fork_https(
self, mock_run_check, mock_run, mock_run_interactive, mock_subprocess
):
"""Test PR creation on a fork with HTTPS URLs."""
mock_run_check.return_value = False
mock_run_check.side_effect = [False, True]

# Mock upstream remote exists
mock_subprocess.return_value = mock.Mock(
Expand Down Expand Up @@ -194,7 +198,7 @@ def test_create_pr_fork_https(
@mock.patch("acp.run_check")
def test_create_pr_not_github(self, mock_run_check, mock_run):
"""Test PR creation fails for non-GitHub repos."""
mock_run_check.return_value = False
mock_run_check.side_effect = [False, True]

mock_run.side_effect = [
"main",
Expand All @@ -214,7 +218,7 @@ def test_create_pr_upstream_not_github(
self, mock_run_check, mock_run, mock_run_interactive, mock_subprocess
):
"""Test PR creation fails when upstream is not GitHub."""
mock_run_check.return_value = False
mock_run_check.side_effect = [False, True]

# Mock upstream remote exists but not GitHub
mock_subprocess.return_value = mock.Mock(
Expand All @@ -239,7 +243,7 @@ def test_create_pr_interactive_non_fork(
self, mock_run_check, mock_run, mock_run_interactive, mock_subprocess
):
"""Test interactive mode on non-fork repo."""
mock_run_check.return_value = False
mock_run_check.side_effect = [False, True]

# No upstream remote
mock_subprocess.return_value = mock.Mock(returncode=1, stdout="")
Expand Down Expand Up @@ -267,7 +271,7 @@ def test_create_pr_interactive_fork(
self, mock_run_check, mock_run, mock_run_interactive, mock_subprocess, capsys
):
"""Test interactive mode on fork with correct URL format."""
mock_run_check.return_value = False
mock_run_check.side_effect = [False, True]

# Mock upstream remote exists
mock_subprocess.return_value = mock.Mock(
Expand Down Expand Up @@ -303,7 +307,7 @@ def test_create_pr_interactive_non_fork_url(
self, mock_run_check, mock_run, mock_run_interactive, mock_subprocess, capsys
):
"""Test interactive mode URL format for non-fork."""
mock_run_check.return_value = False
mock_run_check.side_effect = [False, True]

# No upstream remote
mock_subprocess.return_value = mock.Mock(returncode=1, stdout="")
Expand Down Expand Up @@ -357,7 +361,7 @@ def test_create_pr_with_merge(
self, mock_run_check, mock_run, mock_run_interactive, mock_subprocess, capsys
):
"""Test PR creation with immediate merge."""
mock_run_check.return_value = False # Has staged changes
mock_run_check.side_effect = [False, True] # Has staged changes

api_check_count = {"count": 0}

Expand Down Expand Up @@ -469,7 +473,7 @@ def test_create_pr_with_auto_merge(
self, mock_run_check, mock_run, mock_run_interactive, mock_subprocess, capsys
):
"""Test PR creation with auto-merge enabled."""
mock_run_check.return_value = False # Has staged changes
mock_run_check.side_effect = [False, True] # Has staged changes

def subprocess_side_effect(*args, **kwargs):
cmd = args[0]
Expand Down Expand Up @@ -531,7 +535,7 @@ def test_create_pr_with_merge_verbose(
self, mock_run_check, mock_run, mock_run_interactive, mock_subprocess, capsys
):
"""Test PR creation with merge in verbose mode."""
mock_run_check.return_value = False # Has staged changes
mock_run_check.side_effect = [False, True] # Has staged changes

api_check_count = {"count": 0}

Expand Down Expand Up @@ -611,7 +615,7 @@ def test_create_pr_with_merge_failure(
self, mock_run_check, mock_run, mock_run_interactive, mock_subprocess, capsys
):
"""Test PR creation when merge fails - should show PR created and error."""
mock_run_check.return_value = False # Has staged changes
mock_run_check.side_effect = [False, True] # Has staged changes

def subprocess_side_effect(*args, **kwargs):
cmd = args[0]
Expand Down Expand Up @@ -665,7 +669,7 @@ def test_create_pr_with_auto_merge_failure(
self, mock_run_check, mock_run, mock_run_interactive, mock_subprocess, capsys
):
"""Test PR creation when auto-merge fails - should show PR created and error."""
mock_run_check.return_value = False # Has staged changes
mock_run_check.side_effect = [False, True] # Has staged changes

def subprocess_side_effect(*args, **kwargs):
cmd = args[0]
Expand Down Expand Up @@ -838,7 +842,7 @@ def test_merge_method_merge(
self, mock_run_check, mock_run, mock_run_interactive, mock_subprocess, capsys
):
"""Test --merge with merge method."""
mock_run_check.return_value = False
mock_run_check.side_effect = [False, True]

def subprocess_side_effect(*args, **kwargs):
cmd = args[0]
Expand Down Expand Up @@ -886,7 +890,7 @@ def test_merge_method_rebase(
self, mock_run_check, mock_run, mock_run_interactive, mock_subprocess, capsys
):
"""Test --merge with rebase method."""
mock_run_check.return_value = False
mock_run_check.side_effect = [False, True]

def subprocess_side_effect(*args, **kwargs):
cmd = args[0]
Expand Down Expand Up @@ -934,6 +938,117 @@ def test_invalid_merge_method(self):
)
assert exc.value.code == 1

@mock.patch("subprocess.run")
@mock.patch("acp.run_interactive")
@mock.patch("acp.run")
@mock.patch("acp.run_check")
def test_create_pr_with_unstaged_changes(
self, mock_run_check, mock_run, mock_run_interactive, mock_subprocess
):
"""Test PR creation with unstaged changes stashes and restores them."""
# First call: has staged changes (False = has changes)
# Second call: has unstaged changes (False = has unstaged changes)
mock_run_check.side_effect = [False, False]

def subprocess_side_effect(*args, **kwargs):
cmd = args[0]
# Upstream check - return error (no upstream)
if "upstream" in str(cmd):
return mock.Mock(returncode=1, stdout="", stderr="")
# Stash pop - return success
elif "stash" in str(cmd) and "pop" in str(cmd):
return mock.Mock(returncode=0, stdout="", stderr="")
# Default
return mock.Mock(returncode=0, stdout="", stderr="")

mock_subprocess.side_effect = subprocess_side_effect

mock_run.side_effect = [
"main", # get current branch
"testuser", # get gh username
"[email protected]:user/repo.git", # git remote get-url origin
None, # git checkout -b
# git commit now uses run_interactive, not run
# git push now uses run_interactive, not run
None, # git stash push
None, # git checkout original
"https://github.com/user/repo/pull/1", # gh pr create
]

acp.create_pr("test commit", verbose=False, body="")

# Verify stash push was called with unique ID
stash_push_calls = [
call
for call in mock_run.call_args_list
if len(call[0]) > 0
and "stash" in str(call[0][0])
and "push" in str(call[0][0])
]
assert len(stash_push_calls) == 1
# Verify the stash message contains acp-stash prefix
assert "acp-stash-" in str(stash_push_calls[0])

# Verify stash pop was called via subprocess.run
stash_pop_calls = [
call
for call in mock_subprocess.call_args_list
if len(call[0]) > 0
and "stash" in str(call[0][0])
and "pop" in str(call[0][0])
]
assert len(stash_pop_calls) == 1

@mock.patch("subprocess.run")
@mock.patch("acp.run_interactive")
@mock.patch("acp.run")
@mock.patch("acp.run_check")
def test_create_pr_with_unstaged_changes_conflict(
self, mock_run_check, mock_run, mock_run_interactive, mock_subprocess, capsys
):
"""Test PR creation with unstaged changes that conflict on stash pop."""
# First call: has staged changes (False = has changes)
# Second call: has unstaged changes (False = has unstaged changes)
mock_run_check.side_effect = [False, False]

def subprocess_side_effect(*args, **kwargs):
cmd = args[0]
# Upstream check - return error (no upstream)
if "upstream" in str(cmd):
return mock.Mock(returncode=1, stdout="", stderr="")
# Stash pop - return failure (conflict)
elif "stash" in str(cmd) and "pop" in str(cmd):
return mock.Mock(
returncode=1,
stdout="",
stderr="CONFLICT (content): Merge conflict in file.txt",
)
# Default
return mock.Mock(returncode=0, stdout="", stderr="")

mock_subprocess.side_effect = subprocess_side_effect

mock_run.side_effect = [
"main", # get current branch
"testuser", # get gh username
"[email protected]:user/repo.git", # git remote get-url origin
None, # git checkout -b
# git commit now uses run_interactive, not run
# git push now uses run_interactive, not run
None, # git stash push
None, # git checkout original
"https://github.com/user/repo/pull/1", # gh pr create
]

acp.create_pr("test commit", verbose=False, body="")

# Verify warning message was printed
captured = capsys.readouterr()
assert "Failed to automatically restore stashed changes" in captured.err
assert "acp-stash-" in captured.err
assert "git stash apply" in captured.err
assert "git stash drop" in captured.err

@mock.patch("subprocess.run")
@mock.patch("acp.run_interactive")
@mock.patch("acp.run")
Expand All @@ -942,7 +1057,7 @@ def test_merge_with_branch_already_deleted(
self, mock_run_check, mock_run, mock_run_interactive, mock_subprocess, capsys
):
"""Test merge succeeds when branch check shows it's already deleted by GitHub."""
mock_run_check.return_value = False
mock_run_check.side_effect = [False, True]

def subprocess_side_effect(*args, **kwargs):
cmd = args[0]
Expand Down Expand Up @@ -993,7 +1108,7 @@ def test_merge_with_http_404_only(
self, mock_run_check, mock_run, mock_run_interactive, mock_subprocess, capsys
):
"""Test merge succeeds when branch check returns HTTP 404."""
mock_run_check.return_value = False
mock_run_check.side_effect = [False, True]

def subprocess_side_effect(*args, **kwargs):
cmd = args[0]
Expand Down
Loading