diff --git a/BUILD.bazel b/BUILD.bazel index 7a1e4aa..c85d8b9 100644 --- a/BUILD.bazel +++ b/BUILD.bazel @@ -16,6 +16,7 @@ py_binary( requirement("ansicolors"), requirement("simple-term-menu"), requirement("asciitree"), + requirement("argcomplete"), ] ) diff --git a/README.md b/README.md index 65bd579..8e68e53 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,8 @@ There is also a [xar](https://github.com/facebookincubator/xar/) version it shou ### Pip ``` -pip3 install rockset-stacky +1. Clone this repository +2. From this repository root run `pip install -e .` ``` ### Manual @@ -20,12 +21,47 @@ pip3 install rockset-stacky 1. asciitree 2. ansicolors 3. simple-term-menu +4. argcomplete (for tab completion) ``` -pip3 install asciitree ansicolors simple-term-menu +pip3 install asciitree ansicolors simple-term-menu argcomplete ``` After which `stacky` can be directly run with `./src/stacky/stacky.py`. We would recommend symlinking `stacky.py` into your path so you can use it anywhere +## Tab Completion + +Stacky supports tab completion for branch names in bash and zsh. To enable it: + +### One-time setup +```bash +# Install argcomplete +pip3 install argcomplete + +# Enable global completion (recommended) +activate-global-python-argcomplete +``` + +### Per-session setup (alternative) +If you prefer not to use global completion, you can enable it per session: +```bash +# For bash/zsh +eval "$(register-python-argcomplete stacky)" +``` + +### Permanent setup (alternative) +Add the completion to your shell config: +```bash +# For bash - add to ~/.bashrc +eval "$(register-python-argcomplete stacky)" + +# For zsh - add to ~/.zshrc +eval "$(register-python-argcomplete stacky)" +``` + +After setup, you can use tab completion with commands like: +- `stacky checkout ` - completes branch names +- `stacky adopt ` - completes branch names +- `stacky branch checkout ` - completes branch names ## Accessing Github Stacky doesn't use any git or Github APIs. It expects `git` and `gh` cli commands to work and be properly configured. For instructions on installing the github cli `gh` please read their [documentation](https://cli.github.com/manual/). @@ -33,14 +69,18 @@ Stacky doesn't use any git or Github APIs. It expects `git` and `gh` cli command ## Usage `stacky` stores all information locally, within your git repository Syntax is as follows: -- `stacky info`: show all stacks , add `-pr` if you want to see GitHub PR numbers (slows things down a bit) +- `stacky info`: show all stacks , add `-pr` if you want to see GitHub PR numbers (slows things down a bit) +- `stacky inbox [--compact]`: show all active GitHub pull requests for the current user, organized by status (waiting on you, waiting on review, approved, and PRs awaiting your review). Use `--compact` or `-c` for a condensed one-line-per-PR view with clickable PR numbers. +- `stacky prs`: interactive PR management tool that allows you to select and edit PR descriptions. Shows a simple menu of all your open PRs and PRs awaiting your review, then opens your preferred editor (from `$EDITOR` environment variable) to modify the selected PR's description. - `stacky branch`: per branch commands (shortcut: `stacky b`) - `stacky branch up` (`stacky b u`): move down the stack (towards `master`) - - `stacky branch down` (`stacky b d`): move down the stack (towards `master`) + - `stacky branch down` (`stacky b d`): move down the stack (towards `master`) - `stacky branch new `: create a new branch on top of the current one -- `stacky commit [-m ] [--amend] [--allow-empty]`: wrapper around `git commit` that syncs everything upstack + - `stacky branch commit [-m ] [-a]`: create a new branch and commit changes in one command +- `stacky commit [-m ] [--amend] [--allow-empty] [-a]`: wrapper around `git commit` that syncs everything upstack - `stacky amend`: will amend currently tracked changes to top commit -- Based on the first argument (`stack` vs `upstack` vs `downstack`), the following commands operate on the entire current stack, everything upstack from the current PR (inclusive), or everything downstack from the current PR: +- `stacky fold [--allow-empty]`: fold current branch into its parent branch and delete the current branch. Any children of the current branch become children of the parent branch. Uses cherry-pick by default, or merge if `use_merge` is enabled in config. Use `--allow-empty` to allow empty commits during cherry-pick. +- Based on the first argument (`stack` vs `upstack` vs `downstack`), the following commands operate on the entire current stack, everything upstack from the current PR (inclusive), or everything downstack from the current PR: - `stacky stack info [--pr]` - `stacky stack sync`: sync (rebase) branches in the stack on top of their parents - `stacky stack push [--no-pr]`: push to origin, optionally not creating PRs if they don’t exist @@ -56,12 +96,12 @@ The indicators (`*`, `~`, `!`) mean: ``` $ stacky --help usage: stacky [-h] [--color {always,auto,never}] - {continue,info,commit,amend,branch,b,stack,s,upstack,us,downstack,ds,update,import,adopt,land,push,sync,checkout,co,sco} ... + {continue,info,commit,amend,branch,b,stack,s,upstack,us,downstack,ds,update,import,adopt,land,push,sync,checkout,co,sco,inbox,prs,fold} ... Handle git stacks positional arguments: - {continue,info,commit,amend,branch,b,stack,s,upstack,us,downstack,ds,update,import,adopt,land,push,sync,checkout,co,sco} + {continue,info,commit,amend,branch,b,stack,s,upstack,us,downstack,ds,update,import,adopt,land,push,sync,checkout,co,sco,inbox,prs,fold} continue Continue previously interrupted command info Stack info commit Commit @@ -71,12 +111,16 @@ positional arguments: upstack (us) Operations on the current upstack downstack (ds) Operations on the current downstack update Update repo + import Import Graphite stack adopt Adopt one branch land Land bottom-most PR on current stack push Alias for downstack push sync Alias for stack sync checkout (co) Checkout a branch sco Checkout a branch in this stack + inbox List all active GitHub pull requests for the current user + prs Interactive PR management - select and edit PR descriptions + fold Fold current branch into parent branch and delete current branch optional arguments: -h, --help show this help message and exit @@ -166,6 +210,7 @@ In the file you have sections and each sections define some parameters. We currently have the following sections: * UI + * GIT List of parameters for each sections: @@ -174,6 +219,44 @@ List of parameters for each sections: * change_to_main: boolean with a default value of `False`, by default `stacky` will stop doing action is you are not in a valid stack (ie. a branch that was created or adopted by stacky), when set to `True` `stacky` will first change to `main` or `master` *when* the current branch is not a valid stack. * change_to_adopted: boolean with a default value of `False`, when set to `True` `stacky` will change the current branch to the adopted one. * share_ssh_session: boolean with a default value of `False`, when set to `True` `stacky` will create a shared `ssh` session to the `github.com` server. This is useful when you are pushing a stack of diff and you have some kind of 2FA on your ssh key like the ed25519-sk. + * compact_pr_display: boolean with a default value of `False`, when set to `True` `stacky info --pr` will show a compact format displaying only the PR number and status emoji (✅ approved, ❌ changes requested, 🔄 waiting for review, 🚧 draft) without the PR title. Both compact and full formats include clickable links to the PRs. + * enable_stack_comment: boolean with a default value of `True`, when set to `False` `stacky` will not post stack comments to GitHub PRs showing the entire stack structure. Disable this if you don't want automated stack comments in your PR descriptions. + +### GIT + * use_merge: boolean with a default value of `False`, when set to `True` `stacky` will use `git merge` instead of `git rebase` for sync operations and `stacky fold` will merge the child branch into the parent instead of cherry-picking individual commits. + * use_force_push: boolean with a default value of `True`, controls whether `stacky` can use force push when pushing branches. + +### Example Configuration + +Here's a complete example of a `.stackyconfig` file with all available options: + +```ini +[UI] +# Skip confirmation prompts (useful for automation) +skip_confirm = False + +# Automatically change to main/master when not in a valid stack +change_to_main = False + +# Change to the adopted branch after running 'stacky adopt' +change_to_adopted = False + +# Create shared SSH session for multiple operations (helpful with 2FA) +share_ssh_session = False + +# Show compact format for 'stacky info --pr' (just number and emoji) +compact_pr_display = False + +# Enable posting stack comments to GitHub PRs +enable_stack_comment = True + +[GIT] +# Use git merge instead of rebase for sync operations +use_merge = False + +# Allow force push when pushing branches +use_force_push = True +``` ## License diff --git a/src/stacky/stacky.py b/src/stacky/stacky.py index 9da39aa..8119ac2 100755 --- a/src/stacky/stacky.py +++ b/src/stacky/stacky.py @@ -1,4 +1,5 @@ #!/usr/bin/env python3 +# PYTHON_ARGCOMPLETE_OK # GitHub helper for stacked diffs. # @@ -37,6 +38,7 @@ from argparse import ArgumentParser from typing import Dict, FrozenSet, Generator, List, NewType, Optional, Tuple, TypedDict, Union +import argcomplete # type: ignore import asciitree # type: ignore import colors # type: ignore from simple_term_menu import TerminalMenu # type: ignore @@ -108,19 +110,23 @@ class StackyConfig: share_ssh_session: bool = False use_merge: bool = False use_force_push: bool = True + compact_pr_display: bool = False + enable_stack_comment: bool = True def read_one_config(self, config_path: str): rawconfig = configparser.ConfigParser() rawconfig.read(config_path) if rawconfig.has_section("UI"): - self.skip_confirm = bool(rawconfig.get("UI", "skip_confirm", fallback=self.skip_confirm)) - self.change_to_main = bool(rawconfig.get("UI", "change_to_main", fallback=self.change_to_main)) - self.change_to_adopted = bool(rawconfig.get("UI", "change_to_adopted", fallback=self.change_to_adopted)) - self.share_ssh_session = bool(rawconfig.get("UI", "share_ssh_session", fallback=self.share_ssh_session)) + self.skip_confirm = rawconfig.getboolean("UI", "skip_confirm", fallback=self.skip_confirm) + self.change_to_main = rawconfig.getboolean("UI", "change_to_main", fallback=self.change_to_main) + self.change_to_adopted = rawconfig.getboolean("UI", "change_to_adopted", fallback=self.change_to_adopted) + self.share_ssh_session = rawconfig.getboolean("UI", "share_ssh_session", fallback=self.share_ssh_session) + self.compact_pr_display = rawconfig.getboolean("UI", "compact_pr_display", fallback=self.compact_pr_display) + self.enable_stack_comment = rawconfig.getboolean("UI", "enable_stack_comment", fallback=self.enable_stack_comment) if rawconfig.has_section("GIT"): - self.use_merge = bool(rawconfig.get("GIT", "use_merge", fallback=self.use_merge)) - self.use_merge = bool(rawconfig.get("GIT", "use_force_push", fallback=self.use_force_push)) + self.use_merge = rawconfig.getboolean("GIT", "use_merge", fallback=self.use_merge) + self.use_force_push = rawconfig.getboolean("GIT", "use_force_push", fallback=self.use_force_push) CONFIG: Optional[StackyConfig] = None @@ -134,11 +140,19 @@ def get_config() -> StackyConfig: def read_config() -> StackyConfig: - root_dir = get_top_level_dir() config = StackyConfig() - config_paths = [f"{root_dir}/.stackyconfig", os.path.expanduser("~/.stackyconfig")] + config_paths = [os.path.expanduser("~/.stackyconfig")] + + try: + root_dir = get_top_level_dir() + config_paths.append(f"{root_dir}/.stackyconfig") + except Exception: + # Not in a git repository, skip the repo-level config + debug("Not in a git repository, skipping repo-level config") + pass for p in config_paths: + # Root dir config overwrites home directory config if os.path.exists(p): config.read_one_config(p) @@ -257,6 +271,15 @@ def get_all_branches() -> List[BranchName]: return [BranchName(b) for b in branches.split("\n") if b] +def branch_name_completer(prefix, parsed_args, **kwargs): + """Argcomplete completer function for branch names.""" + try: + branches = get_all_branches() + return [branch for branch in branches if branch.startswith(prefix)] + except Exception: + return [] + + def get_real_stack_bottom() -> Optional[BranchName]: # type: ignore [return] """ return the actual stack bottom for this current repo @@ -312,6 +335,9 @@ def get_pr_info(branch: BranchName, *, full: bool = False) -> PRInfos: "title", "baseRefName", "headRefName", + "reviewDecision", + "reviewRequests", + "isDraft", ] if full: fields += ["commits"] @@ -561,6 +587,28 @@ def make_tree(b: StackBranch) -> BranchesTree: return BranchesTree(dict([make_tree_node(b)])) +def get_pr_status_emoji(pr_info) -> str: + """Get the status emoji for a PR based on review state""" + if not pr_info: + return "" + + review_decision = pr_info.get('reviewDecision') + review_requests = pr_info.get('reviewRequests', []) + is_draft = pr_info.get('isDraft', False) + + if is_draft: + # Draft PRs are waiting on author + return " 🚧" + elif review_decision == "APPROVED": + return " ✅" + elif review_requests and len(review_requests) > 0: + # Has pending review requests - waiting on review + return " 🔄" + else: + # No pending review requests, likely needs changes or author action + return " ❌" + + def format_name(b: StackBranch, *, colorize: bool) -> str: prefix = "" severity = 0 @@ -580,9 +628,18 @@ def format_name(b: StackBranch, *, colorize: bool) -> str: suffix = "" if b.open_pr_info: suffix += " " - suffix += fmt("(#{})", b.open_pr_info["number"], color=colorize, fg="blue") - suffix += " " - suffix += fmt("{}", b.open_pr_info["title"], color=colorize, fg="blue") + # Make the PR info a clickable link + pr_url = b.open_pr_info["url"] + pr_number = b.open_pr_info["number"] + status_emoji = get_pr_status_emoji(b.open_pr_info) + + if get_config().compact_pr_display: + # Compact: just number and emoji + suffix += fmt("(\033]8;;{}\033\\#{}{}\033]8;;\033\\)", pr_url, pr_number, status_emoji, color=colorize, fg="blue") + else: + # Full: number, emoji, and title + pr_title = b.open_pr_info["title"] + suffix += fmt("(\033]8;;{}\033\\#{}{} {}\033]8;;\033\\)", pr_url, pr_number, status_emoji, pr_title, color=colorize, fg="blue") return prefix + fmt("{}", b.name, color=colorize, fg=fg) + suffix @@ -713,6 +770,14 @@ def cmd_info(stack: StackBranchSet, args): print_forest(forest) +def cmd_log(stack: StackBranchSet, args): + config = get_config() + if config.use_merge: + run(["git", "log", "--no-merges", "--first-parent"], out=True) + else: + run(["git", "log"], out=True) + + def checkout(branch): info("Checking out branch {}", branch) run(["git", "checkout", branch], out=True) @@ -763,6 +828,35 @@ def cmd_branch_new(stack: StackBranchSet, args): run(CmdArgs(["git", "update-ref", "refs/stack-parent/{}".format(name), b.commit, ""])) +def cmd_branch_commit(stack: StackBranchSet, args): + """Create a new branch and commit all changes with the provided message""" + global CURRENT_BRANCH + + # First create the new branch (same logic as cmd_branch_new) + b = stack.stack[CURRENT_BRANCH] + assert b.commit + name = args.name + create_branch(name) + run(CmdArgs(["git", "update-ref", "refs/stack-parent/{}".format(name), b.commit, ""])) + + # Update global CURRENT_BRANCH since we just checked out the new branch + CURRENT_BRANCH = BranchName(name) + + # Reload the stack to include the new branch + load_stack_for_given_branch(stack, CURRENT_BRANCH) + + # Now commit all changes with the provided message (or open editor if no message) + do_commit( + stack, + message=args.message, + amend=False, + allow_empty=False, + edit=True, + add_all=args.add_all, + no_verify=args.no_verify, + ) + + def cmd_branch_checkout(stack: StackBranchSet, args): branch_name = args.name if branch_name is None: @@ -809,7 +903,7 @@ def confirm(msg: str = "Proceed?"): cout("{} [yes/no] ", msg, fg="yellow") sys.stderr.flush() r = input().strip().lower() - if r == "yes": + if r == "yes" or r == "y": break if r == "no": die("Not confirmed") @@ -918,6 +1012,137 @@ def create_gh_pr(b: StackBranch, prefix: str): ) +def generate_stack_string(forest: BranchesTreeForest, current_branch: StackBranch) -> str: + """Generate a string representation of the PR stack""" + stack_lines = [] + + def add_branch_to_stack(b: StackBranch, depth: int): + if b.name in STACK_BOTTOMS: + return + + indent = " " * depth + pr_info = "" + if b.open_pr_info: + pr_number = b.open_pr_info['number'] + + # Add approval status emoji using same logic as stacky inbox + status_emoji = get_pr_status_emoji(b.open_pr_info) + pr_info = f" (#{pr_number}{status_emoji})" + + # Add arrow indicator for current branch (the one this PR represents) + current_indicator = " ← (CURRENT PR)" if b.name == current_branch.name else "" + + stack_lines.append(f"{indent}- {b.name}{pr_info}{current_indicator}") + + def traverse_tree(tree: BranchesTree, depth: int): + for _, (branch, children) in tree.items(): + add_branch_to_stack(branch, depth) + traverse_tree(children, depth + 1) + + for tree in forest: + traverse_tree(tree, 0) + + if not stack_lines: + return "" + + return "\n".join([ + "", + "**Stack:**", + *stack_lines, + "" + ]) + + +def get_branch_depth(branch: StackBranch, forest: BranchesTreeForest) -> int: + """Calculate the depth of a branch in the stack""" + depth = 0 + b = branch + while b.parent and b.parent.name not in STACK_BOTTOMS: + depth += 1 + b = b.parent + return depth + + +def extract_stack_comment(body: str) -> str: + """Extract existing stack comment from PR body""" + if not body: + return "" + + # Look for the stack comment pattern using HTML comments as sentinels + import re + pattern = r'.*?' + match = re.search(pattern, body, re.DOTALL) + + if match: + return match.group(0).strip() + return "" + + +def get_complete_stack_forest_for_branch(branch: StackBranch) -> BranchesTreeForest: + """Get the complete stack forest containing the given branch""" + # Find the root of the stack + root = branch + while root.parent and root.parent.name not in STACK_BOTTOMS: + root = root.parent + + # Create a forest with just this root's complete tree + return BranchesTreeForest([make_tree(root)]) + + +def add_or_update_stack_comment(branch: StackBranch, complete_forest: BranchesTreeForest): + """Add or update stack comment in PR body using a pre-computed complete forest""" + if not branch.open_pr_info: + return + + pr_number = branch.open_pr_info["number"] + + # Get current PR body + pr_data = json.loads( + run_always_return( + CmdArgs([ + "gh", "pr", "view", str(pr_number), + "--json", "body" + ]) + ) + ) + + current_body = pr_data.get("body", "") + stack_string = generate_stack_string(complete_forest, branch) + + if not stack_string: + return + + existing_stack = extract_stack_comment(current_body) + + if not existing_stack: + # No existing stack comment, add one + if current_body: + new_body = f"{current_body}\n\n{stack_string}" + else: + new_body = stack_string + + cout("Adding stack comment to PR #{}\n", pr_number, fg="green") + run(CmdArgs([ + "gh", "pr", "edit", str(pr_number), + "--body", new_body + ]), out=True) + else: + # Verify existing stack comment is correct + if existing_stack != stack_string: + # Update the stack comment + updated_body = current_body.replace(existing_stack, stack_string) + + cout("Updating stack comment in PR #{}\n", pr_number, fg="yellow") + run(CmdArgs([ + "gh", "pr", "edit", str(pr_number), + "--body", updated_body + ]), out=True) + else: + cout("✓ Stack comment in PR #{} is already correct\n", pr_number, fg="green") + + + + def do_push( forest: BranchesTreeForest, *, @@ -1013,16 +1238,12 @@ def do_push( # To do so we need to pickup the current commit of the branch, the branch name, the # parent branch and it's parent commit and call .git/hooks/pre-push cout("Pushing {}\n", b.name, fg="green") + cmd_args = ["git", "push"] + if get_config().use_force_push: + cmd_args.append("-f") + cmd_args.extend([b.remote, "{}:{}".format(b.name, b.remote_branch)]) run( - CmdArgs( - [ - "git", - "push", - "-f" if get_config().use_force_push else "", - b.remote, - "{}:{}".format(b.name, b.remote_branch), - ] - ), + CmdArgs(cmd_args), out=True, ) if pr_action == PR_FIX_BASE: @@ -1044,6 +1265,37 @@ def do_push( elif pr_action == PR_CREATE: create_gh_pr(b, prefix) + # Handle stack comments for PRs + if pr and get_config().enable_stack_comment: + # Reload PR info to include newly created PRs + load_pr_info_for_forest(forest) + + # Get complete forests for all branches with PRs (grouped by stack root) + complete_forests_by_root = {} + branches_with_prs = [b for b in forest_depth_first(forest) if b.open_pr_info] + + for b in branches_with_prs: + # Find root branch + root = b + while root.parent and root.parent.name not in STACK_BOTTOMS: + root = root.parent + + root_name = root.name + if root_name not in complete_forests_by_root: + # Create complete forest for this root and load PR info once + complete_forest = get_complete_stack_forest_for_branch(b) + load_pr_info_for_forest(complete_forest) + complete_forests_by_root[root_name] = complete_forest + + # Now update stack comments using the cached complete forests + for b in branches_with_prs: + root = b + while root.parent and root.parent.name not in STACK_BOTTOMS: + root = root.parent + + complete_forest = complete_forests_by_root[root.name] + add_or_update_stack_comment(b, complete_forest) + stop_muxed_ssh(remote_name) @@ -1103,8 +1355,8 @@ def set_parent_commit(branch: BranchName, new_commit: Commit, prev_commit: Optio def get_commits_between(a: Commit, b: Commit): lines = run_multiline(CmdArgs(["git", "rev-list", "{}..{}".format(a, b)])) assert lines is not None - return [x.strip() for x in lines.split("\n")] - + # Have to strip the last element because it's empty, rev list includes a new line at the end it seems + return [x.strip() for x in lines.split("\n")][:-1] def inner_do_sync(syncs: List[StackBranch], sync_names: List[BranchName]): print() @@ -1162,7 +1414,7 @@ def cmd_stack_sync(stack: StackBranchSet, args): do_sync(get_current_stack_as_forest(stack)) -def do_commit(stack: StackBranchSet, *, message=None, amend=False, allow_empty=False, edit=True): +def do_commit(stack: StackBranchSet, *, message=None, amend=False, allow_empty=False, edit=True, add_all=False, no_verify=False): b = stack.stack[CURRENT_BRANCH] if not b.parent: die("Do not commit directly on {}", b.name) @@ -1180,8 +1432,12 @@ def do_commit(stack: StackBranchSet, *, message=None, amend=False, allow_empty=F die("Branch {} has no commits, may not amend", b.name) cmd = ["git", "commit"] + if add_all: + cmd += ["-a"] if allow_empty: cmd += ["--allow-empty"] + if no_verify: + cmd += ["--no-verify"] if amend: cmd += ["--amend"] if not edit: @@ -1204,11 +1460,13 @@ def cmd_commit(stack: StackBranchSet, args): amend=args.amend, allow_empty=args.allow_empty, edit=not args.no_edit, + add_all=args.add_all, + no_verify=args.no_verify, ) def cmd_amend(stack: StackBranchSet, args): - do_commit(stack, amend=True, edit=False) + do_commit(stack, amend=True, edit=False, no_verify=args.no_verify) def cmd_upstack_info(stack: StackBranchSet, args): @@ -1428,19 +1686,28 @@ def delete_branches(stack: StackBranchSet, deletes: List[StackBranch]): def cleanup_unused_refs(stack: StackBranchSet): # Clean up stacky bottom branch refs info("Cleaning up unused refs") + + # Get the current list of existing branches in the repository + existing_branches = set(get_all_branches()) + + # Clean up stacky bottom branch refs for non-existent branches stack_bottoms = get_all_stack_bottoms() for bottom in stack_bottoms: - if not bottom in stack.stack: + if bottom not in stack.stack or bottom not in existing_branches: ref = "refs/stacky-bottom-branch/{}".format(bottom) - info("Deleting ref {}".format(ref)) + info("Deleting ref {} (branch {} no longer exists)".format(ref, bottom)) run(CmdArgs(["git", "update-ref", "-d", ref])) + # Clean up stack parent refs for non-existent branches stack_parent_refs = get_all_stack_parent_refs() for br in stack_parent_refs: - if br not in stack.stack: + if br not in stack.stack or br not in existing_branches: ref = "refs/stack-parent/{}".format(br) - old_value = run(CmdArgs(["git", "show-ref", ref])) - info("Deleting ref {}".format(old_value)) + old_value = run(CmdArgs(["git", "show-ref", ref]), check=False) + if old_value: + info("Deleting ref {} (branch {} no longer exists)".format(old_value, br)) + else: + info("Deleting ref refs/stack-parent/{} (branch {} no longer exists)".format(br, br)) run(CmdArgs(["git", "update-ref", "-d", ref])) @@ -1482,6 +1749,7 @@ def cmd_update(stack: StackBranchSet, args): delete_branches(stack, deletes) stop_muxed_ssh(remote) + info("Cleaning up refs for non-existent branches") cleanup_unused_refs(stack) @@ -1657,6 +1925,344 @@ def cmd_land(stack: StackBranchSet, args): cout("\n✓ Success! Run `stacky update` to update local state.\n", fg="green") +def edit_pr_description(pr): + """Edit a PR's description using the user's default editor""" + import tempfile + + cout("Editing PR #{} - {}\n", pr["number"], pr["title"], fg="green") + cout("Current description:\n", fg="yellow") + current_body = pr.get("body", "") + if current_body: + cout("{}\n\n", current_body, fg="gray") + else: + cout("(No description)\n\n", fg="gray") + + # Create a temporary file with the current description + with tempfile.NamedTemporaryFile(mode='w+', suffix='.md', delete=False) as temp_file: + temp_file.write(current_body or "") + temp_file_path = temp_file.name + + try: + # Get the user's preferred editor + editor = os.environ.get('EDITOR', 'vim') + + # Open the editor + result = subprocess.run([editor, temp_file_path]) + if result.returncode != 0: + cout("Editor exited with error, not updating PR description.\n", fg="red") + return + + # Read the edited content + with open(temp_file_path, 'r') as temp_file: + new_body = temp_file.read().strip() + + # Normalize both original and new content for comparison + original_content = (current_body or "").strip() + new_content = new_body.strip() + + # Check if the content actually changed + if new_content == original_content: + cout("No changes made to PR description.\n", fg="yellow") + return + + # Update the PR description using gh CLI + cout("Updating PR description...\n", fg="green") + run(CmdArgs([ + "gh", "pr", "edit", str(pr["number"]), + "--body", new_body + ]), out=True) + + cout("✓ Successfully updated PR #{} description\n", pr["number"], fg="green") + + # Update the PR object for display consistency + pr["body"] = new_body + + except Exception as e: + cout("Error editing PR description: {}\n", str(e), fg="red") + finally: + # Clean up the temporary file + try: + os.unlink(temp_file_path) + except OSError: + pass + + +def cmd_inbox(stack: StackBranchSet, args): + """List all active GitHub pull requests for the current user""" + fields = [ + "number", + "title", + "headRefName", + "baseRefName", + "state", + "url", + "createdAt", + "updatedAt", + "author", + "reviewDecision", + "reviewRequests", + "mergeable", + "mergeStateStatus", + "statusCheckRollup", + "isDraft", + "body" + ] + + # Get all open PRs authored by the current user + my_prs_data = json.loads( + run_always_return( + CmdArgs( + [ + "gh", + "pr", + "list", + "--json", + ",".join(fields), + "--state", + "open", + "--author", + "@me" + ] + ) + ) + ) + + # Get all open PRs where current user is requested as reviewer + review_prs_data = json.loads( + run_always_return( + CmdArgs( + [ + "gh", + "pr", + "list", + "--json", + ",".join(fields), + "--state", + "open", + "--search", + "review-requested:@me" + ] + ) + ) + ) + + # Categorize my PRs based on review status + waiting_on_me = [] + waiting_on_review = [] + approved = [] + + for pr in my_prs_data: + if pr.get("isDraft", False): + # Draft PRs are always waiting on the author (me) + waiting_on_me.append(pr) + elif pr["reviewDecision"] == "APPROVED": + approved.append(pr) + elif pr["reviewRequests"] and len(pr["reviewRequests"]) > 0: + waiting_on_review.append(pr) + else: + # No pending review requests, likely needs changes or author action + waiting_on_me.append(pr) + + # Sort all lists by updatedAt in descending order (most recent first) + waiting_on_me.sort(key=lambda pr: pr["updatedAt"], reverse=True) + waiting_on_review.sort(key=lambda pr: pr["updatedAt"], reverse=True) + approved.sort(key=lambda pr: pr["updatedAt"], reverse=True) + review_prs_data.sort(key=lambda pr: pr["updatedAt"], reverse=True) + + def get_check_status(pr): + """Get a summary of merge check status""" + if not pr.get("statusCheckRollup") or len(pr.get("statusCheckRollup")) == 0: + return "", "gray" + + rollup = pr["statusCheckRollup"] + + # statusCheckRollup is a list of checks, determine overall state + states = [] + for check in rollup: + if isinstance(check, dict) and "state" in check: + states.append(check["state"]) + + if not states: + return "", "gray" + + # Determine overall status based on individual check states + if "FAILURE" in states or "ERROR" in states: + return "✗ Checks failed", "red" + elif "PENDING" in states or "QUEUED" in states: + return "⏳ Checks running", "yellow" + elif all(state == "SUCCESS" for state in states): + return "✓ Checks passed", "green" + else: + return f"Checks mixed", "yellow" + + def display_pr_compact(pr, show_author=False): + """Display a single PR in compact format""" + check_text, check_color = get_check_status(pr) + + # Create clickable link for PR number + pr_number_text = f"#{pr['number']}" + clickable_number = f"\033]8;;{pr['url']}\033\\\033[96m{pr_number_text}\033[0m\033]8;;\033\\" + cout("{} ", clickable_number) + cout("{} ", pr["title"], fg="white") + cout("({}) ", pr["headRefName"], fg="gray") + + if show_author: + cout("by {} ", pr["author"]["login"], fg="gray") + + if pr.get("isDraft", False): + cout("[DRAFT] ", fg="orange") + + if check_text: + cout("{} ", check_text, fg=check_color) + + cout("Updated: {}\n", pr["updatedAt"][:10], fg="gray") + + def display_pr_full(pr, show_author=False): + """Display a single PR in full format""" + check_text, check_color = get_check_status(pr) + + # Create clickable link for PR number + pr_number_text = f"#{pr['number']}" + clickable_number = f"\033]8;;{pr['url']}\033\\\033[96m{pr_number_text}\033[0m\033]8;;\033\\" + cout("{} ", clickable_number) + cout("{}\n", pr["title"], fg="white") + cout(" {} -> {}\n", pr["headRefName"], pr["baseRefName"], fg="gray") + + if show_author: + cout(" Author: {}\n", pr["author"]["login"], fg="gray") + + if pr.get("isDraft", False): + cout(" [DRAFT]\n", fg="orange") + + if check_text: + cout(" {}\n", check_text, fg=check_color) + + cout(" {}\n", pr["url"], fg="blue") + cout(" Updated: {}, Created: {}\n\n", pr["updatedAt"][:10], pr["createdAt"][:10], fg="gray") + + def display_pr_list(prs, show_author=False): + """Display a list of PRs in the chosen format""" + for pr in prs: + if args.compact: + display_pr_compact(pr, show_author) + else: + display_pr_full(pr, show_author) + + # Display categorized authored PRs + if waiting_on_me: + cout("Your PRs - Waiting on You:\n", fg="red") + display_pr_list(waiting_on_me) + cout("\n") + + if waiting_on_review: + cout("Your PRs - Waiting on Review:\n", fg="yellow") + display_pr_list(waiting_on_review) + cout("\n") + + if approved: + cout("Your PRs - Approved:\n", fg="green") + display_pr_list(approved) + cout("\n") + + if not my_prs_data: + cout("No active pull requests authored by you.\n", fg="green") + + # Display PRs waiting for review + if review_prs_data: + cout("Pull Requests Awaiting Your Review:\n", fg="yellow") + display_pr_list(review_prs_data, show_author=True) + else: + cout("No pull requests awaiting your review.\n", fg="yellow") + + +def cmd_prs(stack: StackBranchSet, args): + """Interactive PR management - select and edit PR descriptions""" + fields = [ + "number", + "title", + "headRefName", + "baseRefName", + "state", + "url", + "createdAt", + "updatedAt", + "author", + "reviewDecision", + "reviewRequests", + "mergeable", + "mergeStateStatus", + "statusCheckRollup", + "isDraft", + "body" + ] + + # Get all open PRs authored by the current user + my_prs_data = json.loads( + run_always_return( + CmdArgs( + [ + "gh", + "pr", + "list", + "--json", + ",".join(fields), + "--state", + "open", + "--author", + "@me" + ] + ) + ) + ) + + # Get all open PRs where current user is requested as reviewer + review_prs_data = json.loads( + run_always_return( + CmdArgs( + [ + "gh", + "pr", + "list", + "--json", + ",".join(fields), + "--state", + "open", + "--search", + "review-requested:@me" + ] + ) + ) + ) + + # Combine all PRs + all_prs = my_prs_data + review_prs_data + if not all_prs: + cout("No active pull requests found.\n", fg="green") + return + + if not IS_TERMINAL: + die("Interactive PR management requires a terminal") + + # Create simple menu options + menu_options = [] + for pr in all_prs: + # Simple menu line with just PR number and title + menu_options.append(f"#{pr['number']} {pr['title']}") + + menu_options.append("Exit") + + while True: + cout("\nSelect a PR to edit its description:\n", fg="cyan") + menu = TerminalMenu(menu_options, cursor_index=0) + idx = menu.show() + + if idx is None or idx == len(menu_options) - 1: # Exit selected or cancelled + break + + selected_pr = all_prs[idx] + edit_pr_description(selected_pr) + + def main(): logging.basicConfig(format=_LOGGING_FORMAT, level=logging.INFO) try: @@ -1697,16 +2303,23 @@ def main(): info_parser.add_argument("--pr", action="store_true", help="Get PR info (slow)") info_parser.set_defaults(func=cmd_info) + # log + log_parser = subparsers.add_parser("log", help="Show git log with conditional merge handling") + log_parser.set_defaults(func=cmd_log) + # commit commit_parser = subparsers.add_parser("commit", help="Commit") commit_parser.add_argument("-m", help="Commit message", dest="message") commit_parser.add_argument("--amend", action="store_true", help="Amend last commit") commit_parser.add_argument("--allow-empty", action="store_true", help="Allow empty commit") commit_parser.add_argument("--no-edit", action="store_true", help="Skip editor") + commit_parser.add_argument("-a", action="store_true", help="Add all files to commit", dest="add_all") + commit_parser.add_argument("--no-verify", action="store_true", help="Bypass pre-commit and commit-msg hooks") commit_parser.set_defaults(func=cmd_commit) # amend amend_parser = subparsers.add_parser("amend", help="Shortcut for amending last commit") + amend_parser.add_argument("--no-verify", action="store_true", help="Bypass pre-commit and commit-msg hooks") amend_parser.set_defaults(func=cmd_amend) # branch @@ -1722,8 +2335,15 @@ def main(): branch_new_parser.add_argument("name", help="Branch name") branch_new_parser.set_defaults(func=cmd_branch_new) + branch_commit_parser = branch_subparsers.add_parser("commit", help="Create a new branch and commit all changes") + branch_commit_parser.add_argument("name", help="Branch name") + branch_commit_parser.add_argument("-m", help="Commit message", dest="message") + branch_commit_parser.add_argument("-a", action="store_true", help="Add all files to commit", dest="add_all") + branch_commit_parser.add_argument("--no-verify", action="store_true", help="Bypass pre-commit and commit-msg hooks") + branch_commit_parser.set_defaults(func=cmd_branch_commit) + branch_checkout_parser = branch_subparsers.add_parser("checkout", aliases=["co"], help="Checkout a branch") - branch_checkout_parser.add_argument("name", help="Branch name", nargs="?") + branch_checkout_parser.add_argument("name", help="Branch name", nargs="?").completer = branch_name_completer branch_checkout_parser.set_defaults(func=cmd_branch_checkout) # stack @@ -1768,7 +2388,7 @@ def main(): upstack_onto_parser.set_defaults(func=cmd_upstack_onto) upstack_as_parser = upstack_subparsers.add_parser("as", help="Upstack branch this as a new stack bottom") - upstack_as_parser.add_argument("target", help="bottom, restack this branch as a new stack bottom") + upstack_as_parser.add_argument("target", help="bottom, restack this branch as a new stack bottom").completer = branch_name_completer upstack_as_parser.set_defaults(func=cmd_upstack_as) # downstack @@ -1799,12 +2419,12 @@ def main(): # import import_parser = subparsers.add_parser("import", help="Import Graphite stack") import_parser.add_argument("--force", "-f", action="store_true", help="Bypass confirmation") - import_parser.add_argument("name", help="Foreign stack top") + import_parser.add_argument("name", help="Foreign stack top").completer = branch_name_completer import_parser.set_defaults(func=cmd_import) # adopt adopt_parser = subparsers.add_parser("adopt", help="Adopt one branch") - adopt_parser.add_argument("name", help="Branch name") + adopt_parser.add_argument("name", help="Branch name").completer = branch_name_completer adopt_parser.set_defaults(func=cmd_adopt) # land @@ -1828,12 +2448,27 @@ def main(): sync_parser.set_defaults(func=cmd_stack_sync) checkout_parser = subparsers.add_parser("checkout", aliases=["co"], help="Checkout a branch") - checkout_parser.add_argument("name", help="Branch name", nargs="?") + checkout_parser.add_argument("name", help="Branch name", nargs="?").completer = branch_name_completer checkout_parser.set_defaults(func=cmd_branch_checkout) checkout_parser = subparsers.add_parser("sco", help="Checkout a branch in this stack") checkout_parser.set_defaults(func=cmd_stack_checkout) + # inbox + inbox_parser = subparsers.add_parser("inbox", help="List all active GitHub pull requests for the current user") + inbox_parser.add_argument("--compact", "-c", action="store_true", help="Show compact view") + inbox_parser.set_defaults(func=cmd_inbox) + + # prs + prs_parser = subparsers.add_parser("prs", help="Interactive PR management - select and edit PR descriptions") + prs_parser.set_defaults(func=cmd_prs) + + # fold + fold_parser = subparsers.add_parser("fold", help="Fold current branch into parent branch and delete current branch") + fold_parser.add_argument("--allow-empty", action="store_true", help="Allow empty commits during cherry-pick") + fold_parser.set_defaults(func=cmd_fold) + + argcomplete.autocomplete(parser) args = parser.parse_args() logging.basicConfig(format=_LOGGING_FORMAT, level=LOGLEVELS[args.log_level], force=True) @@ -1864,10 +2499,33 @@ def main(): if CURRENT_BRANCH not in stack.stack: die("Current branch {} is not in a stack", CURRENT_BRANCH) - sync_names = state["sync"] - syncs = [stack.stack[n] for n in sync_names] - - inner_do_sync(syncs, sync_names) + if "sync" in state: + # Continue sync operation + sync_names = state["sync"] + syncs = [stack.stack[n] for n in sync_names] + inner_do_sync(syncs, sync_names) + elif "fold" in state: + # Continue fold operation + fold_state = state["fold"] + inner_do_fold( + stack, + fold_state["fold_branch"], + fold_state["parent_branch"], + fold_state["commits"], + fold_state["children"], + fold_state["allow_empty"] + ) + elif "merge_fold" in state: + # Continue merge-based fold operation + merge_fold_state = state["merge_fold"] + finish_merge_fold_operation( + stack, + merge_fold_state["fold_branch"], + merge_fold_state["parent_branch"], + merge_fold_state["children"] + ) + else: + die("Unknown operation in progress") else: # TODO restore the current branch after changing the branch on some commands for # instance `info` @@ -1893,5 +2551,237 @@ def main(): sys.exit(1) +def cmd_fold(stack: StackBranchSet, args): + """Fold current branch into parent branch and delete current branch""" + global CURRENT_BRANCH + + if CURRENT_BRANCH not in stack.stack: + die("Current branch {} is not in a stack", CURRENT_BRANCH) + + b = stack.stack[CURRENT_BRANCH] + + if not b.parent: + die("Cannot fold stack bottom branch {}", CURRENT_BRANCH) + + if b.parent.name in STACK_BOTTOMS: + die("Cannot fold into stack bottom branch {}", b.parent.name) + + if not b.is_synced_with_parent(): + die( + "Branch {} is not synced with parent {}, sync before folding", + b.name, + b.parent.name, + ) + + # Get commits to be applied + commits_to_apply = get_commits_between(b.parent_commit, b.commit) + if not commits_to_apply: + info("No commits to fold from {} into {}", b.name, b.parent.name) + else: + cout("Folding {} commits from {} into {}\n", len(commits_to_apply), b.name, b.parent.name, fg="green") + + # Get children that need to be reparented + children = list(b.children) + if children: + cout("Reparenting {} children to {}\n", len(children), b.parent.name, fg="yellow") + for child in children: + cout(" {} -> {}\n", child.name, b.parent.name, fg="gray") + + # Switch to parent branch + checkout(b.parent.name) + CURRENT_BRANCH = b.parent.name + + # Choose between merge and cherry-pick based on config + if get_config().use_merge: + # Merge approach: merge the child branch into parent + inner_do_merge_fold(stack, b.name, b.parent.name, [child.name for child in children]) + else: + # Cherry-pick approach: apply individual commits + if commits_to_apply: + # Reverse the list since get_commits_between returns newest first + commits_to_apply = list(reversed(commits_to_apply)) + # Use inner_do_fold for state management + inner_do_fold(stack, b.name, b.parent.name, commits_to_apply, [child.name for child in children], args.allow_empty) + else: + # No commits to apply, just finish the fold operation + finish_fold_operation(stack, b.name, b.parent.name, [child.name for child in children]) + + return # Early return since both paths handle completion + + +def inner_do_merge_fold(stack: StackBranchSet, fold_branch_name: BranchName, parent_branch_name: BranchName, + children_names: List[BranchName]): + """Perform merge-based fold operation with state management""" + print() + + # Save state for potential continuation + with open(TMP_STATE_FILE, "w") as f: + json.dump({ + "branch": CURRENT_BRANCH, + "merge_fold": { + "fold_branch": fold_branch_name, + "parent_branch": parent_branch_name, + "children": children_names, + } + }, f) + os.replace(TMP_STATE_FILE, STATE_FILE) # make the write atomic + + cout("Merging {} into {}\n", fold_branch_name, parent_branch_name, fg="green") + result = run(CmdArgs(["git", "merge", fold_branch_name]), check=False) + if result is None: + die("Merge failed for branch {}. Please resolve conflicts and run `stacky continue`", fold_branch_name) + + # Merge successful, complete the fold operation + finish_merge_fold_operation(stack, fold_branch_name, parent_branch_name, children_names) + + +def finish_merge_fold_operation(stack: StackBranchSet, fold_branch_name: BranchName, + parent_branch_name: BranchName, children_names: List[BranchName]): + """Complete the merge-based fold operation after merge is successful""" + global CURRENT_BRANCH + + # Get the updated branches from the stack + fold_branch = stack.stack.get(fold_branch_name) + parent_branch = stack.stack[parent_branch_name] + + if not fold_branch: + # Branch might have been deleted already, just finish up + cout("✓ Merge fold operation completed\n", fg="green") + return + + # Update parent branch commit in stack + parent_branch.commit = get_commit(parent_branch_name) + + # Reparent children + for child_name in children_names: + if child_name in stack.stack: + child = stack.stack[child_name] + info("Reparenting {} from {} to {}", child.name, fold_branch.name, parent_branch.name) + child.parent = parent_branch + parent_branch.children.add(child) + fold_branch.children.discard(child) + set_parent(child.name, parent_branch.name) + # Update the child's parent commit to the new parent's tip + set_parent_commit(child.name, parent_branch.commit, child.parent_commit) + child.parent_commit = parent_branch.commit + + # Remove the folded branch from its parent's children + parent_branch.children.discard(fold_branch) + + # Delete the branch + info("Deleting branch {}", fold_branch.name) + run(CmdArgs(["git", "branch", "-D", fold_branch.name])) + + # Clean up stack parent ref + run(CmdArgs(["git", "update-ref", "-d", "refs/stack-parent/{}".format(fold_branch.name)])) + + # Remove from stack + stack.remove(fold_branch.name) + + cout("✓ Successfully merged and folded {} into {}\n", fold_branch.name, parent_branch.name, fg="green") + + +def inner_do_fold(stack: StackBranchSet, fold_branch_name: BranchName, parent_branch_name: BranchName, + commits_to_apply: List[str], children_names: List[BranchName], allow_empty: bool): + """Continue folding operation from saved state""" + print() + + # If no commits to apply, skip cherry-picking and go straight to cleanup + if not commits_to_apply: + finish_fold_operation(stack, fold_branch_name, parent_branch_name, children_names) + return + + while commits_to_apply: + with open(TMP_STATE_FILE, "w") as f: + json.dump({ + "branch": CURRENT_BRANCH, + "fold": { + "fold_branch": fold_branch_name, + "parent_branch": parent_branch_name, + "commits": commits_to_apply, + "children": children_names, + "allow_empty": allow_empty + } + }, f) + os.replace(TMP_STATE_FILE, STATE_FILE) # make the write atomic + + commit = commits_to_apply.pop() + + # Check if this commit would be empty by doing a dry-run cherry-pick + dry_run_result = run(CmdArgs(["git", "cherry-pick", "--no-commit", commit]), check=False) + if dry_run_result is not None: + # Check if there are any changes staged + has_changes = run(CmdArgs(["git", "diff", "--cached", "--quiet"]), check=False) is None + + # Reset the working directory and index since we only wanted to test + run(CmdArgs(["git", "reset", "--hard", "HEAD"])) + + if not has_changes: + cout("Skipping empty commit {}\n", commit[:8], fg="yellow") + continue + else: + # Cherry-pick failed during dry run, reset and try normal cherry-pick + # This could happen due to conflicts, so we'll let the normal cherry-pick handle it + run(CmdArgs(["git", "reset", "--hard", "HEAD"]), check=False) + + cout("Cherry-picking commit {}\n", commit[:8], fg="green") + cherry_pick_cmd = ["git", "cherry-pick"] + if allow_empty: + cherry_pick_cmd.append("--allow-empty") + cherry_pick_cmd.append(commit) + result = run(CmdArgs(cherry_pick_cmd), check=False) + if result is None: + die("Cherry-pick failed for commit {}. Please resolve conflicts and run `stacky continue`", commit) + + # All commits applied successfully, now finish the fold operation + finish_fold_operation(stack, fold_branch_name, parent_branch_name, children_names) + + +def finish_fold_operation(stack: StackBranchSet, fold_branch_name: BranchName, + parent_branch_name: BranchName, children_names: List[BranchName]): + """Complete the fold operation after all commits are applied""" + global CURRENT_BRANCH + + # Get the updated branches from the stack + fold_branch = stack.stack.get(fold_branch_name) + parent_branch = stack.stack[parent_branch_name] + + if not fold_branch: + # Branch might have been deleted already, just finish up + cout("✓ Fold operation completed\n", fg="green") + return + + # Update parent branch commit in stack + parent_branch.commit = get_commit(parent_branch_name) + + # Reparent children + for child_name in children_names: + if child_name in stack.stack: + child = stack.stack[child_name] + info("Reparenting {} from {} to {}", child.name, fold_branch.name, parent_branch.name) + child.parent = parent_branch + parent_branch.children.add(child) + fold_branch.children.discard(child) + set_parent(child.name, parent_branch.name) + # Update the child's parent commit to the new parent's tip + set_parent_commit(child.name, parent_branch.commit, child.parent_commit) + child.parent_commit = parent_branch.commit + + # Remove the folded branch from its parent's children + parent_branch.children.discard(fold_branch) + + # Delete the branch + info("Deleting branch {}", fold_branch.name) + run(CmdArgs(["git", "branch", "-D", fold_branch.name])) + + # Clean up stack parent ref + run(CmdArgs(["git", "update-ref", "-d", "refs/stack-parent/{}".format(fold_branch.name)])) + + # Remove from stack + stack.remove(fold_branch.name) + + cout("✓ Successfully folded {} into {}\n", fold_branch.name, parent_branch.name, fg="green") + + if __name__ == "__main__": main()