diff --git a/src/stack_pr/cli.py b/src/stack_pr/cli.py index cf52324..069347f 100755 --- a/src/stack_pr/cli.py +++ b/src/stack_pr/cli.py @@ -1285,6 +1285,13 @@ def command_land(args: CommonArgs) -> None: # already be there from the metadata that commits need to have by that # point. set_base_branches(st, args.target) + + # If the current branch contains commits from the stack, rebase it onto the + # updated stack tip in the end so it reuses the same rewritten commits as + # the PR branches. + top_commit = st[-1].commit.commit_id() + need_to_rebase_current = is_ancestor(top_commit, current_branch, verbose=args.verbose) + print_stack(st, links=args.hyperlinks) # Verify that the stack is correct before trying to land it. @@ -1293,6 +1300,11 @@ def command_land(args: CommonArgs) -> None: # All good, land the bottommost PR! land_pr(st[0], remote=args.remote, target=args.target, verbose=args.verbose) + # Refresh the local view of the target branch after the merge. + run_shell_command(["git", "fetch", "--prune", args.remote], quiet=not args.verbose) + + current_branch_base = f"{args.remote}/{args.target}" + # The rest of the stack now needs to be rebased. if len(st) > 1: log(h("Rebasing the rest of the stack"), level=1) @@ -1300,6 +1312,13 @@ def command_land(args: CommonArgs) -> None: print_stack(prs_to_rebase, links=args.hyperlinks, level=1) for e in prs_to_rebase: rebase_pr(e, remote=args.remote, target=args.target, verbose=args.verbose) + + # Resolve the rewritten tip to a commit hash before the local stack + # branches are deleted below. + current_branch_base = get_command_output( + ["git", "rev-parse", prs_to_rebase[-1].head] + ).strip() + # Change the target of the new bottom-most PR in the stack to 'target' run_shell_command( ["gh", "pr", "edit", prs_to_rebase[0].pr, "-B", args.target], @@ -1317,10 +1336,12 @@ def command_land(args: CommonArgs) -> None: ["git", "rebase", f"{args.remote}/{args.target}", args.target], quiet=not args.verbose, ) - run_shell_command( - ["git", "rebase", f"{args.remote}/{args.target}", current_branch], - quiet=not args.verbose, - ) + + rebase_base = current_branch_base if need_to_rebase_current else f"{args.remote}/{args.target}" + cmd = ["git", "rebase", rebase_base, current_branch] + if need_to_rebase_current: + cmd.append("--committer-date-is-author-date") + run_shell_command(cmd, quiet=not args.verbose) log(h(blue("SUCCESS!"))) diff --git a/tests/test_land.py b/tests/test_land.py new file mode 100644 index 0000000..2d1d2c4 --- /dev/null +++ b/tests/test_land.py @@ -0,0 +1,112 @@ +import sys +from pathlib import Path +from types import SimpleNamespace + +sys.path.append(str(Path(__file__).parent.parent / "src")) + +from stack_pr import cli + + +def make_args() -> cli.CommonArgs: + return cli.CommonArgs( + base="base", + head="head", + remote="origin", + target="main", + hyperlinks=False, + verbose=False, + branch_name_template="$USERNAME/stack/$ID", + show_tips=False, + land_disabled=False, + ) + + +def make_entry(head: str, pr: str, commit_id: str) -> SimpleNamespace: + return SimpleNamespace( + head=head, + pr=pr, + commit=SimpleNamespace(commit_id=lambda: commit_id), + ) + + +def test_land_rebases_current_branch_onto_updated_stack_tip( + monkeypatch, +) -> None: + commands: list[list[str]] = [] + stack = [make_entry("stack/1", "pr-1", "commit-1"), make_entry("stack/2", "pr-2", "commit-2")] + + monkeypatch.setattr(cli, "get_current_branch_name", lambda: "feature") + monkeypatch.setattr(cli, "should_update_local_base", lambda **kwargs: False) + monkeypatch.setattr(cli, "get_stack", lambda **kwargs: stack) + monkeypatch.setattr(cli, "set_base_branches", lambda *args, **kwargs: None) + monkeypatch.setattr(cli, "print_stack", lambda *args, **kwargs: None) + monkeypatch.setattr(cli, "verify", lambda *args, **kwargs: None) + monkeypatch.setattr(cli, "land_pr", lambda *args, **kwargs: None) + monkeypatch.setattr(cli, "rebase_pr", lambda *args, **kwargs: None) + monkeypatch.setattr(cli, "delete_local_branches", lambda *args, **kwargs: None) + monkeypatch.setattr(cli, "branch_exists", lambda branch: False) + monkeypatch.setattr( + cli, + "get_command_output", + lambda cmd, **kwargs: "rebased-stack-tip\n" + if cmd == ["git", "rev-parse", "stack/2"] + else "", + ) + monkeypatch.setattr( + cli, + "is_ancestor", + lambda ancestor, descendant, *, verbose: ancestor == "commit-2" + and descendant == "feature", + ) + + def fake_run_shell_command(cmd, *, quiet, check=True, **kwargs): + commands.append(list(cmd)) + return SimpleNamespace(returncode=0, stdout=b"", stderr=b"") + + monkeypatch.setattr(cli, "run_shell_command", fake_run_shell_command) + + cli.command_land(make_args()) + + assert [ + "git", + "rebase", + "rebased-stack-tip", + "feature", + "--committer-date-is-author-date", + ] in commands + assert ["git", "rebase", "stack/2", "feature", "--committer-date-is-author-date"] not in commands + assert ["git", "rebase", "origin/main", "feature"] not in commands + + +def test_land_refreshes_remote_target_before_rebasing_current_branch( + monkeypatch, +) -> None: + commands: list[list[str]] = [] + stack = [make_entry("stack/1", "pr-1", "commit-1")] + + monkeypatch.setattr(cli, "get_current_branch_name", lambda: "feature") + monkeypatch.setattr(cli, "should_update_local_base", lambda **kwargs: False) + monkeypatch.setattr(cli, "get_stack", lambda **kwargs: stack) + monkeypatch.setattr(cli, "set_base_branches", lambda *args, **kwargs: None) + monkeypatch.setattr(cli, "print_stack", lambda *args, **kwargs: None) + monkeypatch.setattr(cli, "verify", lambda *args, **kwargs: None) + monkeypatch.setattr(cli, "land_pr", lambda *args, **kwargs: None) + monkeypatch.setattr(cli, "delete_local_branches", lambda *args, **kwargs: None) + monkeypatch.setattr(cli, "branch_exists", lambda branch: False) + monkeypatch.setattr( + cli, + "is_ancestor", + lambda ancestor, descendant, *, verbose: ancestor == "commit-1" + and descendant == "feature", + ) + + def fake_run_shell_command(cmd, *, quiet, check=True, **kwargs): + commands.append(list(cmd)) + return SimpleNamespace(returncode=0, stdout=b"", stderr=b"") + + monkeypatch.setattr(cli, "run_shell_command", fake_run_shell_command) + + cli.command_land(make_args()) + + assert ["git", "fetch", "--prune", "origin"] in commands + assert ["git", "rebase", "origin/main", "feature", "--committer-date-is-author-date"] in commands \ No newline at end of file