From d29f65ded8dcfab16aa2d42633126cfc6253b548 Mon Sep 17 00:00:00 2001 From: Mehdi Amini Date: Mon, 13 Apr 2026 06:04:00 -0700 Subject: [PATCH 1/2] Rename stack-pr references to stack-mr in comments and messages Assisted-by: Claude Code --- src/stack_pr/cli.py | 34 +++++++++++++++++----------------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/src/stack_pr/cli.py b/src/stack_pr/cli.py index 3714406..79eb7b2 100755 --- a/src/stack_pr/cli.py +++ b/src/stack_pr/cli.py @@ -1,7 +1,7 @@ -# stack-pr: a tool for working with stacked PRs on gitlab. +# stack-mr: a tool for working with stacked MRs on gitlab. # # --------------- -# stack-pr submit +# stack-mr submit # --------------- # # Semantics: @@ -9,20 +9,20 @@ # 2. For each commit since merge base do: # a. If it doesnt have stack info: # - create a new head branch for it -# - create a new PR for it +# - create a new MR for it # - base branch will be the previous commit in the stack # b. If it has stack info: verify its correctness. # 3. Make sure all commits in the stack are annotated with stack info # 4. Push all the head branches # # If 'submit' succeeds, you'll get all commits annotated with links to the -# corresponding PRs and names of the head branches. All the branches will be -# pushed to remote, and PRs are properly created and interconnected. Base -# branch of each PR will be the head branch of the previous PR, or 'main' for -# the first PR in the stack. +# corresponding MRs and names of the head branches. All the branches will be +# pushed to remote, and MRs are properly created and interconnected. Base +# branch of each MR will be the head branch of the previous MR, or 'main' for +# the first MR in the stack. # # ------------- -# stack-pr land +# stack-mr land # ------------- # # Semantics: @@ -31,18 +31,18 @@ # 3. Check that the stack info is valid. If not, bail. # 4. For each commit in the stack, from oldest to newest: # - set base branch to point to main -# - merge the corresponding PR +# - merge the corresponding MR # -# If 'land' succeeds, all the PRs from the stack will be merged into 'main', +# If 'land' succeeds, all the MRs from the stack will be merged into 'main', # all the corresponding remote and local branches deleted. # # ---------------- -# stack-pr abandon +# stack-mr abandon # ---------------- # # Semantics: # For all commits in the stack that have valid stack-info: -# Close the corresponding PR, delete the remote and local branch, remove the +# Close the corresponding MR, delete the remote and local branch, remove the # stack-info from commit message. # # ===----------------------------------------------------------------------=== # @@ -149,17 +149,17 @@ """ UPDATE_STACK_TIP = """ If you'd like to push your local changes first, you can use the following command to update the stack: - $ stack-pr export -B {top_commit}~{stack_size} -H {top_commit}""" + $ stack-mr export -B {top_commit}~{stack_size} -H {top_commit}""" EXPORT_STACK_TIP = """ You can use the following command to do that: - $ stack-pr export -B {top_commit}~{stack_size} -H {top_commit} + $ stack-mr export -B {top_commit}~{stack_size} -H {top_commit} """ LAND_STACK_TIP = """ To land it, you could run: - $ stack-pr land -B {top_commit}~{stack_size} -H {top_commit} + $ stack-mr land -B {top_commit}~{stack_size} -H {top_commit} If you'd like to land stack except the top N commits, you could use the following command: - $ stack-pr land -B {top_commit}~{stack_size} -H {top_commit}~N + $ stack-mr land -B {top_commit}~{stack_size} -H {top_commit}~N If you prefer to merge via the gitlab web UI, please don't forget to edit commit message on the merge page! If you use the default commit message filled by the web UI, links to other MRs from the stack will be included in the commit message. @@ -1244,7 +1244,7 @@ def main(): args = parser.parse_args() if not args.command: - print(h(red("Invalid usage of the stack-pr command."))) + print(h(red("Invalid usage of the stack-mr command."))) parser.print_help() return From 6ad6e805093ca5a7740e5f120f6a9caa6a127fd6 Mon Sep 17 00:00:00 2001 From: Mehdi Amini Date: Mon, 13 Apr 2026 06:12:18 -0700 Subject: [PATCH 2/2] Add --dry-run mode to print commands without executing them Side-effecting shell commands are printed as [dry-run] instead of executed; read-only queries via get_command_output still run so the command logic can proceed normally. Co-Authored-By: Claude Sonnet 4.6 --- src/stack_pr/cli.py | 46 +++++++++++++++++++++------------- src/stack_pr/shell_commands.py | 22 ++++++++++++++-- 2 files changed, 48 insertions(+), 20 deletions(-) diff --git a/src/stack_pr/cli.py b/src/stack_pr/cli.py index 79eb7b2..cba2196 100755 --- a/src/stack_pr/cli.py +++ b/src/stack_pr/cli.py @@ -52,7 +52,7 @@ import os import re import shlex -from subprocess import SubprocessError +from subprocess import SubprocessError, list2cmdline from stack_pr.git import ( branch_exists, @@ -61,6 +61,7 @@ get_glab_username, get_uncommitted_changes, ) +from stack_pr import shell_commands from stack_pr.shell_commands import get_command_output, run_shell_command from typing import List, NamedTuple, Optional, Pattern @@ -294,19 +295,13 @@ def pprint(self): return s def is_mergeable(self): - out = get_command_output( - ["glab", "mr", "view", last(self.pr), "-F", "json"], - ) - mr_data = json.loads(out) - - # Check for merge_status. - if "merge_status" in mr_data: - return mr_data["merge_status"].strip() == "can_be_merged" - # Fallback to detailed_merge_status. - if "detailed_merge_status" in mr_data: - return mr_data["detailed_merge_status"].strip() == "can_be_merged" - # If neither field is present, assume not mergeable. - return False + if shell_commands.is_dry_run(): + out = "{\"merge_status\": \"can_be_merged\"}" + else: + out = get_command_output( + ["glab", "mr", "view", last(self.pr), "-F", "json"], + ) + mr_data = json.loads(out) def __repr__(self): return self.pprint() @@ -451,8 +446,7 @@ def verify(st: List[StackEntry], check_base: bool = False): error(ERROR_STACKINFO_BAD_LINK.format(**locals())) raise RuntimeError - ghinfo = get_command_output( - [ + cmd = [ "glab", "mr", "view", @@ -460,7 +454,10 @@ def verify(st: List[StackEntry], check_base: bool = False): "-F", "json", ] - ) + if shell_commands.is_dry_run(): + print(f"[dry-run] {list2cmdline(cmd)}") + continue + ghinfo = get_command_output(cmd) d = json.loads(ghinfo) for required_field in ["state", "iid", "target_branch", "source_branch"]: if required_field not in d: @@ -618,7 +615,11 @@ def create_pr(e: StackEntry, is_draft: bool, reviewer: str = ""): cmd.append("--draft") try: - r = get_command_output(cmd) + if shell_commands.is_dry_run(): + print(f"[dry-run] {list2cmdline(cmd)}") + r = "https://repo/merge_requests/1234567890" + else: + r = get_command_output(cmd) except Exception: error(ERROR_CANT_CREATE_PR.format(**locals())) raise @@ -1188,6 +1189,12 @@ def create_argparser() -> argparse.ArgumentParser: common_parser.add_argument( "-T", "--target", default=get_default_branch(), help="Remote target branch" ) + common_parser.add_argument( + "--dry-run", + action="store_true", + default=False, + help="Print commands without executing them", + ) parser_submit = subparsers.add_parser( "export", @@ -1248,6 +1255,9 @@ def main(): parser.print_help() return + if args.dry_run: + shell_commands.set_dry_run(True) + common_args = CommonArgs.from_args(args) current_branch = get_current_branch_name() diff --git a/src/stack_pr/shell_commands.py b/src/stack_pr/shell_commands.py index a5f23d5..8d11d78 100644 --- a/src/stack_pr/shell_commands.py +++ b/src/stack_pr/shell_commands.py @@ -4,6 +4,21 @@ ShellCommand = Iterable[Union[str, Path]] +_DRY_RUN: bool = False + + +def set_dry_run(enabled: bool) -> None: + """Enable or disable dry-run mode globally. + + When dry-run is enabled, side-effecting shell commands (those not capturing + output) are printed instead of executed. + """ + global _DRY_RUN + _DRY_RUN = enabled + +def is_dry_run() -> bool: + """Check if dry-run mode is enabled.""" + return _DRY_RUN def run_shell_command( cmd: ShellCommand, *, check: bool = True, **kwargs: Any @@ -24,9 +39,12 @@ def run_shell_command( """ if "shell" in kwargs: raise ValueError("shell support has been removed") - _ = subprocess.list2cmdline(cmd) + cmd_list = list(map(str, cmd)) + if _DRY_RUN and "capture_output" not in kwargs: + print(f"[dry-run] {subprocess.list2cmdline(cmd_list)}") + return subprocess.CompletedProcess(cmd_list, 0) kwargs.update({"check": check}) - return subprocess.run(list(map(str, cmd)), **kwargs) + return subprocess.run(cmd_list, **kwargs) def get_command_output(cmd: ShellCommand, **kwargs: Any) -> str: