Skip to content
Open
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
47 changes: 39 additions & 8 deletions src/stack_pr/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -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)
Expand Down Expand Up @@ -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,
Expand Down
30 changes: 30 additions & 0 deletions src/stack_pr/git.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
99 changes: 99 additions & 0 deletions tests/test_land.py
Original file line number Diff line number Diff line change
@@ -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)