From c594d8472e2b6737feb3afd115d9bc121dcc3937 Mon Sep 17 00:00:00 2001 From: drisspg Date: Mon, 9 Mar 2026 19:29:24 +0000 Subject: [PATCH] Make rebasing working with worktrees + other local checkouts --- src/stack_pr/cli.py | 47 +++++++++++++++++---- src/stack_pr/git.py | 30 ++++++++++++++ tests/test_land.py | 99 +++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 168 insertions(+), 8 deletions(-) create mode 100644 tests/test_land.py diff --git a/src/stack_pr/cli.py b/src/stack_pr/cli.py index cf52324..b73e041 100755 --- a/src/stack_pr/cli.py +++ b/src/stack_pr/cli.py @@ -64,6 +64,7 @@ from subprocess import SubprocessError from stack_pr.git import ( + branch_checked_out_in_other_worktree, branch_exists, check_gh_installed, get_current_branch_name, @@ -1261,6 +1262,7 @@ def command_land(args: CommonArgs) -> None: log(h("LAND"), level=1) current_branch = get_current_branch_name() + stack_base = args.base if should_update_local_base( head=args.head, @@ -1269,13 +1271,32 @@ def command_land(args: CommonArgs) -> None: target=args.target, verbose=args.verbose, ): - update_local_base( - base=args.base, remote=args.remote, target=args.target, verbose=args.verbose + base_worktree = ( + branch_checked_out_in_other_worktree(args.base) + if branch_exists(args.base) + else None ) - run_shell_command(["git", "checkout", current_branch], quiet=not args.verbose) + if base_worktree is None: + update_local_base( + base=args.base, + remote=args.remote, + target=args.target, + verbose=args.verbose, + ) + run_shell_command(["git", "checkout", current_branch], quiet=not args.verbose) + else: + stack_base = f"{args.remote}/{args.target}" + log( + h( + f"Skipping update of local branch {args.base}: checked out in" + f" worktree at {base_worktree}. Using {stack_base} as stack" + " base." + ), + level=1, + ) # Determine what commits belong to the stack - st = get_stack(base=args.base, head=args.head, verbose=args.verbose) + st = get_stack(base=stack_base, head=args.head, verbose=args.verbose) if not st: log(h("Empty stack!"), level=1) log(h(blue("SUCCESS!")), level=1) @@ -1313,10 +1334,20 @@ def command_land(args: CommonArgs) -> None: # If local branch {target} exists, rebase it on the remote/target if branch_exists(args.target): - run_shell_command( - ["git", "rebase", f"{args.remote}/{args.target}", args.target], - quiet=not args.verbose, - ) + target_worktree = branch_checked_out_in_other_worktree(args.target) + if target_worktree is None: + run_shell_command( + ["git", "rebase", f"{args.remote}/{args.target}", args.target], + quiet=not args.verbose, + ) + else: + log( + h( + f"Skipping update of local branch {args.target}: checked out in" + f" worktree at {target_worktree}." + ), + level=1, + ) run_shell_command( ["git", "rebase", f"{args.remote}/{args.target}", current_branch], quiet=not args.verbose, diff --git a/src/stack_pr/git.py b/src/stack_pr/git.py index 89252c9..3bc890b 100644 --- a/src/stack_pr/git.py +++ b/src/stack_pr/git.py @@ -76,6 +76,36 @@ def branch_exists(branch: str, repo_dir: Path | None = None) -> bool: raise GitError("Not inside a valid git repository.") +def branch_checked_out_in_other_worktree( + branch: str, repo_dir: Path | None = None +) -> Path | None: + """Return the worktree path if the branch is checked out elsewhere.""" + try: + worktree_info = get_command_output( + ["git", "worktree", "list", "--porcelain"], cwd=repo_dir + ) + except subprocess.CalledProcessError as e: + if e.returncode == GIT_NOT_A_REPO_ERROR: + raise GitError("Not inside a valid git repository.") from e + raise + + current_root = get_repo_root(repo_dir).resolve() + current_worktree: Path | None = None + + for line in worktree_info.splitlines(): + if line.startswith("worktree "): + current_worktree = Path(line.removeprefix("worktree ")).resolve() + continue + if not line.startswith("branch refs/heads/") or current_worktree is None: + continue + if line.removeprefix("branch refs/heads/") != branch: + continue + if current_worktree != current_root: + return current_worktree + + return None + + def get_current_branch_name(repo_dir: Path | None = None) -> str: """Returns the name of the branch currently checked out. diff --git a/tests/test_land.py b/tests/test_land.py new file mode 100644 index 0000000..86a8110 --- /dev/null +++ b/tests/test_land.py @@ -0,0 +1,99 @@ +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent.parent / "src")) + +from stack_pr.cli import CommonArgs, command_land + + +def test_command_land_skips_target_rebase_checked_out_in_other_worktree( + monkeypatch, +) -> None: + commands: list[list[str]] = [] + logs: list[str] = [] + + monkeypatch.setattr("stack_pr.cli.get_current_branch_name", lambda: "feature") + monkeypatch.setattr( + "stack_pr.cli.should_update_local_base", + lambda **_: False, + ) + monkeypatch.setattr("stack_pr.cli.get_stack", lambda **_: [object()]) + monkeypatch.setattr("stack_pr.cli.set_base_branches", lambda *_, **__: None) + monkeypatch.setattr("stack_pr.cli.print_stack", lambda *_, **__: None) + monkeypatch.setattr("stack_pr.cli.verify", lambda *_, **__: None) + monkeypatch.setattr("stack_pr.cli.land_pr", lambda *_, **__: None) + monkeypatch.setattr("stack_pr.cli.delete_local_branches", lambda *_, **__: None) + monkeypatch.setattr("stack_pr.cli.branch_exists", lambda _: True) + monkeypatch.setattr( + "stack_pr.cli.branch_checked_out_in_other_worktree", + lambda branch: Path("/tmp/other-worktree") if branch == "main" else None, + ) + monkeypatch.setattr( + "stack_pr.cli.run_shell_command", + lambda cmd, quiet=True: commands.append(cmd), + ) + monkeypatch.setattr("stack_pr.cli.log", lambda msg, level=1: logs.append(msg)) + + command_land( + CommonArgs( + base="main", + head="feature", + remote="origin", + target="main", + hyperlinks=False, + verbose=False, + branch_name_template="$USERNAME/stack/$ID", + show_tips=True, + land_disabled=False, + ) + ) + + assert ["git", "rebase", "origin/main", "main"] not in commands + assert ["git", "rebase", "origin/main", "feature"] in commands + assert any("Skipping update of local branch main" in msg for msg in logs) + + +def test_command_land_uses_remote_target_when_base_checked_out_in_other_worktree( + monkeypatch, +) -> None: + stack_bases: list[str] = [] + logs: list[str] = [] + updated_bases: list[str] = [] + + monkeypatch.setattr("stack_pr.cli.get_current_branch_name", lambda: "feature") + monkeypatch.setattr( + "stack_pr.cli.should_update_local_base", + lambda **_: True, + ) + monkeypatch.setattr("stack_pr.cli.branch_exists", lambda branch: branch == "main") + monkeypatch.setattr( + "stack_pr.cli.branch_checked_out_in_other_worktree", + lambda branch: Path("/tmp/other-worktree") if branch == "main" else None, + ) + monkeypatch.setattr( + "stack_pr.cli.update_local_base", + lambda *, base, remote, target, verbose: updated_bases.append(base), + ) + monkeypatch.setattr( + "stack_pr.cli.get_stack", + lambda *, base, head, verbose: stack_bases.append(base) or [], + ) + monkeypatch.setattr("stack_pr.cli.log", lambda msg, level=1: logs.append(msg)) + + command_land( + CommonArgs( + base="main", + head="feature", + remote="origin", + target="main", + hyperlinks=False, + verbose=False, + branch_name_template="$USERNAME/stack/$ID", + show_tips=True, + land_disabled=False, + ) + ) + + assert updated_bases == [] + assert stack_bases == ["origin/main"] + assert any("Using origin/main as stack base." in msg for msg in logs)