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
80 changes: 45 additions & 35 deletions src/stack_pr/cli.py
Original file line number Diff line number Diff line change
@@ -1,28 +1,28 @@
# 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:
# 1. Find merge-base (the most recent commit from 'main' in the current branch)
# 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:
Expand All @@ -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.
#
# ===----------------------------------------------------------------------=== #
Expand All @@ -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,
Expand All @@ -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

Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -451,16 +446,18 @@ 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",
last(e.pr),
"-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:
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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()
Expand Down
22 changes: 20 additions & 2 deletions src/stack_pr/shell_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:
Expand Down