diff --git a/src/stack_pr/cli.py b/src/stack_pr/cli.py index 3714406..cba2196 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. # # ===----------------------------------------------------------------------=== # @@ -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 @@ -149,17 +150,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. @@ -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", @@ -1244,10 +1251,13 @@ 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 + 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: