From 77a5d62a3ab3b1329bc554e0221e8789899f9978 Mon Sep 17 00:00:00 2001 From: Yashwanth Nannapaneni Date: Thu, 1 Aug 2024 13:56:18 -0700 Subject: [PATCH 01/26] Adding upstack as bottom and also adding option to merge vs rebase (#1) For many repositories it is possible to have more than one base branch we want to work off of. For instance if you have a release branch that you don't want to rebase into main but want to work on the release branch. You should be able to have multiple bottom branches in a repo. stacky upstack as bottom will change the current branch to be a bottom branch. We will track all non master branches with a new ref refs/stacky-bottom-branch/branch-name. A bottom branch cannot be restacked onto something else, but another bottom branch can adopt a bottom branch to bring it back into a stack. stacky update will also now clean up unused refs. Tested using git co test_1 && stacky.py branch new test_2 && stacky.py upstack as bottom && stacky.py info stacky update stack restack onto master for a bottom branch stacky adopt stacky info --pr Stacky should work just fine with either rebasing or git merging. This makes a change that adds a [GIT] section to the stacky config and 2 settings use_merge and use_force_merge to change to merge commits and disallowing force pushes. If we disallow force pushes and want to use merging we should also disallowing amending commits, which this pr does. Sample ~/.stackyconfig with these changes set ``` [UI] change_to_main = True [GIT] use_merge = True use_force_push = False ``` --------- Co-authored-by: Yashwanth Nannapaneni --- src/stacky/stacky.py | 183 +++++++++++++++++++++++++++++++++++++++---- 1 file changed, 169 insertions(+), 14 deletions(-) diff --git a/src/stacky/stacky.py b/src/stacky/stacky.py index 0183dd9..d2404e0 100755 --- a/src/stacky/stacky.py +++ b/src/stacky/stacky.py @@ -15,10 +15,13 @@ # to the commit at the tip of the parent branch, as `git update-ref # refs/stack-parent/`. # +# For all bottom branches we maintain a ref, labeling it a bottom_branch refs/stacky-bottom-branch/branch-name +# # When rebasing or restacking, we proceed in depth-first order (from "master" # onwards). After updating a parent branch P, given a child branch C, # we rebase everything from C's PC until C's tip onto P. # +# # That's all there is to it. import configparser @@ -82,7 +85,8 @@ class BranchNCommit: COLOR_STDERR: bool = os.isatty(2) IS_TERMINAL: bool = os.isatty(1) and os.isatty(2) CURRENT_BRANCH: BranchName -STACK_BOTTOMS: FrozenSet[BranchName] = frozenset([BranchName("master"), BranchName("main")]) +STACK_BOTTOMS: set[BranchName] = set([BranchName("master"), BranchName("main")]) +FROZEN_STACK_BOTTOMS: FrozenSet[BranchName] = frozenset([BranchName("master"), BranchName("main")]) STATE_FILE = os.path.expanduser("~/.stacky.state") TMP_STATE_FILE = STATE_FILE + ".tmp" @@ -102,6 +106,8 @@ class StackyConfig: change_to_main: bool = False change_to_adopted: bool = False share_ssh_session: bool = False + use_merge: bool = False + use_force_push: bool = True def read_one_config(self, config_path: str): rawconfig = configparser.ConfigParser() @@ -112,6 +118,10 @@ def read_one_config(self, config_path: str): 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)) + 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)) + CONFIG: Optional[StackyConfig] = None @@ -267,6 +277,8 @@ def get_stack_parent_branch(branch: BranchName) -> Optional[BranchName]: # type p = run(CmdArgs(["git", "config", "branch.{}.merge".format(branch)]), check=False) if p is not None: p = remove_prefix(p, "refs/heads/") + if BranchName(p) == branch: + return None return BranchName(p) @@ -421,6 +433,29 @@ def add(self, name: BranchName, **kwargs) -> StackBranch: self.tops.add(s) return s + def addStackBranch(self, s: StackBranch): + if s.name not in self.stack: + self.stack[s.name] = s + if s.parent is None: + self.bottoms.add(s) + if len(s.children) == 0: + self.tops.add(s) + + return s + + def remove(self, name: BranchName) -> Optional[StackBranch]: + if name in self.stack: + s = self.stack[name] + assert s.name == name + del self.stack[name] + if s in self.tops: + self.tops.remove(s) + if s in self.bottoms: + self.bottoms.remove(s) + return s + + return None + def __repr__(self) -> str: out = f"StackBranchSet: {self.stack}" return out @@ -464,8 +499,40 @@ def load_stack_for_given_branch( return top, [b.branch for b in branches] +def get_branch_name_from_short_ref(ref: str) -> BranchName: + parts = ref.split("/", 1) + if len(parts) != 2: + die("invalid ref: {}".format(ref)) + + return BranchName(parts[1]) + + +def get_all_stack_bottoms() -> List[BranchName]: + branches = run_multiline( + CmdArgs(["git", "for-each-ref", "--format", "%(refname:short)", "refs/stacky-bottom-branch"]) + ) + if branches: + return [get_branch_name_from_short_ref(b) for b in branches.split("\n") if b] + return [] + + +def get_all_stack_parent_refs() -> List[BranchName]: + branches = run_multiline(CmdArgs(["git", "for-each-ref", "--format", "%(refname:short)", "refs/stack-parent"])) + if branches: + return [get_branch_name_from_short_ref(b) for b in branches.split("\n") if b] + return [] + + +def load_all_stack_bottoms(): + branches = run_multiline( + CmdArgs(["git", "for-each-ref", "--format", "%(refname:short)", "refs/stacky-bottom-branch"]) + ) + STACK_BOTTOMS.update(get_all_stack_bottoms()) + + def load_all_stacks(stack: StackBranchSet) -> Optional[StackBranch]: """Given a stack return the top of it, aka the bottom of the tree""" + load_all_stack_bottoms() all_branches = set(get_all_branches()) current_branch_top = None while all_branches: @@ -945,7 +1012,7 @@ def do_push( [ "git", "push", - "-f", + "-f" if get_config().use_force_push else "", b.remote, "{}:{}".format(b.name, b.remote_branch), ] @@ -1035,6 +1102,7 @@ def get_commits_between(a: Commit, b: Commit): def inner_do_sync(syncs: List[StackBranch], sync_names: List[BranchName]): print() + sync_type = "merge" if get_config().use_merge else "rebase" while syncs: with open(TMP_STATE_FILE, "w") as f: json.dump({"branch": CURRENT_BRANCH, "sync": sync_names}, f) @@ -1047,22 +1115,36 @@ def inner_do_sync(syncs: List[StackBranch], sync_names: List[BranchName]): continue if b.parent.commit in get_commits_between(b.parent_commit, b.commit): cout( - "Recording complete rebase of {} on top of {}\n", + "Recording complete {} of {} on top of {}\n", + sync_type, b.name, b.parent.name, fg="green", ) else: - cout("Rebasing {} on top of {}\n", b.name, b.parent.name, fg="green") - r = run( - CmdArgs(["git", "rebase", "--onto", b.parent.name, b.parent_commit, b.name]), - out=True, - check=False, - ) + r = None + if get_config().use_merge: + cout("Merging {} into {}\n", b.parent.name, b.name, fg="green") + run(CmdArgs(["git", "checkout", str(b.name)])) + r = run( + CmdArgs(["git", "merge", b.parent.name]), + out=True, + check=False, + ) + else: + cout("Rebasing {} on top of {}\n", b.name, b.parent.name, fg="green") + r = run( + CmdArgs(["git", "rebase", "--onto", b.parent.name, b.parent_commit, b.name]), + out=True, + check=False, + ) + if r is None: print() die( - "Automatic rebase failed. Please complete the rebase (fix conflicts; `git rebase --continue`), then run `stacky continue`" + "Automatic {0} failed. Please complete the {0} (fix conflicts; `git {0} --continue`), then run `stacky continue`".format( + sync_type + ) ) b.commit = get_commit(b.name) set_parent_commit(b.name, b.parent.commit, b.parent_commit) @@ -1084,6 +1166,10 @@ def do_commit(stack: StackBranchSet, *, message=None, amend=False, allow_empty=F b.name, b.parent.name, ) + + if amend and (get_config().use_merge or not get_config().use_force_push): + die("Amending is not allowed if using git merge or if force pushing is disallowed") + if amend and b.commit == b.parent.commit: die("Branch {} has no commits, may not amend", b.name) @@ -1139,26 +1225,39 @@ def cmd_upstack_sync(stack: StackBranchSet, args): do_sync(get_current_upstack_as_forest(stack)) -def set_parent(branch: BranchName, target: BranchName, *, set_origin: bool = False): +def set_parent(branch: BranchName, target: Optional[BranchName], *, set_origin: bool = False): if set_origin: run(CmdArgs(["git", "config", "branch.{}.remote".format(branch), "."])) + ## If target is none this becomes a new stack bottom run( CmdArgs( [ "git", "config", "branch.{}.merge".format(branch), - "refs/heads/{}".format(target), + "refs/heads/{}".format(target if target is not None else branch), ] ) ) + if target is None: + run( + CmdArgs( + [ + "git", + "update-ref", + "-d", + "refs/stack-parent/{}".format(branch), + ] + ) + ) + def cmd_upstack_onto(stack: StackBranchSet, args): b = stack.stack[CURRENT_BRANCH] if not b.parent: - die("May not restack {}", b.name) + die("may not upstack a stack bottom, use stacky adopt") target = stack.stack[args.target] upstack = get_current_upstack_as_forest(stack) for ub in forest_depth_first(upstack): @@ -1170,6 +1269,27 @@ def cmd_upstack_onto(stack: StackBranchSet, args): do_sync(upstack) +def cmd_upstack_as_base(stack: StackBranchSet): + b = stack.stack[CURRENT_BRANCH] + if not b.parent: + die("Branch {} is already a stack bottom", b.name) + + b.parent = None # type: ignore + stack.remove(b.name) + stack.addStackBranch(b) + set_parent(b.name, None) + + run(CmdArgs(["git", "update-ref", "refs/stacky-bottom-branch/{}".format(b.name), b.commit, ""])) + info("Set {} as new bottom branch".format(b.name)) + + +def cmd_upstack_as(stack: StackBranchSet, args): + if args.target == "bottom": + cmd_upstack_as_base(stack) + else: + die("Invalid target {}, acceptable targets are [base]", args.target) + + def cmd_downstack_info(stack, args): forest = get_current_downstack_as_forest(stack) if args.pr: @@ -1299,6 +1419,25 @@ def delete_branches(stack: StackBranchSet, deletes: List[StackBranch]): run(CmdArgs(["git", "branch", "-D", b.name])) +def cleanup_unused_refs(stack: StackBranchSet): + # Clean up stacky bottom branch refs + info("Cleaning up unused refs") + stack_bottoms = get_all_stack_bottoms() + for bottom in stack_bottoms: + if not bottom in stack.stack: + ref = "refs/stacky-bottom-branch/{}".format(bottom) + info("Deleting ref {}".format(ref)) + run(CmdArgs(["git", "update-ref", "-d", ref])) + + stack_parent_refs = get_all_stack_parent_refs() + for br in stack_parent_refs: + if br not in stack.stack: + ref = "refs/stack-parent/{}".format(br) + old_value = run(CmdArgs(["git", "show-ref", ref])) + info("Deleting ref {}".format(old_value)) + run(CmdArgs(["git", "update-ref", "-d", ref])) + + def cmd_update(stack: StackBranchSet, args): remote = "origin" start_muxed_ssh(remote) @@ -1337,6 +1476,8 @@ def cmd_update(stack: StackBranchSet, args): delete_branches(stack, deletes) stop_muxed_ssh(remote) + cleanup_unused_refs(stack) + def cmd_import(stack: StackBranchSet, args): # Importing has to happen based on PR info, rather than local branch @@ -1411,6 +1552,10 @@ def cmd_adopt(stack: StackBranch, args): """ branch = args.name global CURRENT_BRANCH + + if branch == CURRENT_BRANCH: + die("A branch cannot adopt itself") + if CURRENT_BRANCH not in STACK_BOTTOMS: # TODO remove that, the initialisation code is already dealing with that in fact main_branch = get_real_stack_bottom() @@ -1424,6 +1569,12 @@ def cmd_adopt(stack: StackBranch, args): CURRENT_BRANCH, ", ".join(sorted(STACK_BOTTOMS)), ) + if branch in STACK_BOTTOMS: + if branch in FROZEN_STACK_BOTTOMS: + die("Cannot adopt frozen stack bottoms {}".format(FROZEN_STACK_BOTTOMS)) + # Remove the ref that this is a stack bottom + run(CmdArgs(["git", "update-ref", "-d", "refs/stacky-bottom-branch/{}".format(branch)])) + parent_commit = get_merge_base(CURRENT_BRANCH, branch) set_parent(branch, CURRENT_BRANCH, set_origin=True) set_parent_commit(branch, parent_commit) @@ -1610,6 +1761,10 @@ def main(): upstack_onto_parser.add_argument("target", help="New parent") 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.set_defaults(func=cmd_upstack_as) + # downstack downstack_parser = subparsers.add_parser( "downstack", aliases=["ds"], help="Operations on the current downstack" @@ -1631,7 +1786,7 @@ def main(): downstack_sync_parser.set_defaults(func=cmd_downstack_sync) # update - update_parser = subparsers.add_parser("update", help="Update repo") + update_parser = subparsers.add_parser("update", help="Update repo, all bottom branches must exist in remote") update_parser.add_argument("--force", "-f", action="store_true", help="Bypass confirmation") update_parser.set_defaults(func=cmd_update) From 4a0ffaba4c9712aeb747650a7ff36c291fafadda Mon Sep 17 00:00:00 2001 From: Yashwanth Nannapaneni Date: Thu, 29 May 2025 21:59:23 -0700 Subject: [PATCH 02/26] Adding --a option to stacky commit (#2) --- src/stacky/stacky.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/stacky/stacky.py b/src/stacky/stacky.py index d2404e0..9664d3a 100755 --- a/src/stacky/stacky.py +++ b/src/stacky/stacky.py @@ -1156,7 +1156,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): b = stack.stack[CURRENT_BRANCH] if not b.parent: die("Do not commit directly on {}", b.name) @@ -1174,6 +1174,8 @@ 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 amend: @@ -1198,6 +1200,7 @@ def cmd_commit(stack: StackBranchSet, args): amend=args.amend, allow_empty=args.allow_empty, edit=not args.no_edit, + add_all=args.add_all, ) @@ -1697,6 +1700,7 @@ def main(): 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.set_defaults(func=cmd_commit) # amend From eb4b6e4f5875adc92807e51ceb22378cb3d87df9 Mon Sep 17 00:00:00 2001 From: Yashwanth Nannapaneni Date: Mon, 2 Jun 2025 20:36:56 -0700 Subject: [PATCH 03/26] New command stacky branch commit which will create a branch and commit (#3) * New command stacky branch commit which will create a branch and commit * Updating readme for stacky branch commit --- README.md | 3 ++- src/stacky/stacky.py | 34 ++++++++++++++++++++++++++++++++++ 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 65bd579..c84425a 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,8 @@ Syntax is as follows: - `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 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 stack info [--pr]` diff --git a/src/stacky/stacky.py b/src/stacky/stacky.py index efe4c6b..00106ce 100755 --- a/src/stacky/stacky.py +++ b/src/stacky/stacky.py @@ -763,6 +763,34 @@ 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, + ) + + def cmd_branch_checkout(stack: StackBranchSet, args): branch_name = args.name if branch_name is None: @@ -1726,6 +1754,12 @@ 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.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.set_defaults(func=cmd_branch_checkout) From f961e71bba10cba48cc304f14ea0c38c4679d09f Mon Sep 17 00:00:00 2001 From: Yashwanth Nannapaneni Date: Mon, 2 Jun 2025 20:46:34 -0700 Subject: [PATCH 04/26] Adding new command stacky inbox to show PR inbox (#4) --- README.md | 11 +-- src/stacky/stacky.py | 156 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 163 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index c84425a..4d4b7b9 100644 --- a/README.md +++ b/README.md @@ -33,10 +33,11 @@ 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 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 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 @@ -57,12 +58,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} ... 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} continue Continue previously interrupted command info Stack info commit Commit @@ -72,12 +73,14 @@ 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 optional arguments: -h, --help show this help message and exit diff --git a/src/stacky/stacky.py b/src/stacky/stacky.py index 00106ce..cad709d 100755 --- a/src/stacky/stacky.py +++ b/src/stacky/stacky.py @@ -1688,6 +1688,157 @@ def cmd_land(stack: StackBranchSet, args): cout("\n✓ Success! Run `stacky update` to update local state.\n", fg="green") +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" + ] + + # 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["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 display_pr_list(prs, color="white"): + for pr in prs: + if args.compact: + # Compact format with only PR number clickable: "#123 Title (branch) Updated: date" + # Create clickable link for just the 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") + cout("Updated: {}\n", pr["updatedAt"][:10], fg="gray") + else: + # Full format with clickable 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=color) + cout(" {} -> {}\n", pr["headRefName"], pr["baseRefName"], fg="gray") + cout(" {}\n", pr["url"], fg="blue") + cout(" Updated: {}, Created: {}\n\n", pr["updatedAt"][:10], pr["createdAt"][:10], fg="gray") + + # Display categorized authored PRs + if waiting_on_me: + cout("Your PRs - Waiting on You:\n", fg="red") + display_pr_list(waiting_on_me, "white") + if args.compact: + cout("\n") + else: + cout("\n") + + if waiting_on_review: + cout("Your PRs - Waiting on Review:\n", fg="yellow") + display_pr_list(waiting_on_review, "white") + if args.compact: + cout("\n") + else: + cout("\n") + + if approved: + cout("Your PRs - Approved:\n", fg="green") + display_pr_list(approved, "white") + if args.compact: + cout("\n") + else: + 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") + for pr in review_prs_data: + if args.compact: + # Compact format with only PR number clickable: "#123 Title (branch) Updated: date" + # Create clickable link for just the 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") + cout("by {} ", pr["author"]["login"], fg="gray") + cout("Updated: {}\n", pr["updatedAt"][:10], fg="gray") + else: + # Full format with clickable 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") + cout(" Author: {}\n", pr["author"]["login"], fg="gray") + cout(" {}\n", pr["url"], fg="blue") + cout(" Updated: {}, Created: {}\n\n", pr["updatedAt"][:10], pr["createdAt"][:10], fg="gray") + else: + cout("No pull requests awaiting your review.\n", fg="yellow") + + def main(): logging.basicConfig(format=_LOGGING_FORMAT, level=logging.INFO) try: @@ -1872,6 +2023,11 @@ def main(): 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) + args = parser.parse_args() logging.basicConfig(format=_LOGGING_FORMAT, level=LOGLEVELS[args.log_level], force=True) From bfbb6e8ac7f7af1d8d16f608acfc5c66b6cff537 Mon Sep 17 00:00:00 2001 From: Yashwanth Nannapaneni Date: Tue, 3 Jun 2025 16:29:58 -0700 Subject: [PATCH 05/26] Fixing issue with infinite recursion when getting config not in a git repo (#5) Co-authored-by: Yashwanth Nannapaneni --- src/stacky/stacky.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/stacky/stacky.py b/src/stacky/stacky.py index cad709d..2eb6601 100755 --- a/src/stacky/stacky.py +++ b/src/stacky/stacky.py @@ -134,11 +134,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) From 8d540ff5aae724ef8648ccd30c7d84e2879249a0 Mon Sep 17 00:00:00 2001 From: Yashwanth Nannapaneni Date: Wed, 4 Jun 2025 10:27:00 -0700 Subject: [PATCH 06/26] Adding functionality to stacky inbox to alsio list if checks have passed or failed for a pr (#6) * Adding functionality to stacky inbox to alsio list if checks have passed or failed for a pr * Fixing new line when not displaying checks * Cleaning up some stuff with inbox --------- Co-authored-by: Yashwanth Nannapaneni --- src/stacky/stacky.py | 51 ++++++++++++++++++++++++++++++++++++-------- 1 file changed, 42 insertions(+), 9 deletions(-) diff --git a/src/stacky/stacky.py b/src/stacky/stacky.py index 2eb6601..2f8e283 100755 --- a/src/stacky/stacky.py +++ b/src/stacky/stacky.py @@ -1709,7 +1709,10 @@ def cmd_inbox(stack: StackBranchSet, args): "updatedAt", "author", "reviewDecision", - "reviewRequests" + "reviewRequests", + "mergeable", + "mergeStateStatus", + "statusCheckRollup" ] # Get all open PRs authored by the current user @@ -1770,8 +1773,36 @@ def cmd_inbox(stack: StackBranchSet, args): 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_list(prs, color="white"): for pr in prs: + check_text, check_color = get_check_status(pr) + if args.compact: # Compact format with only PR number clickable: "#123 Title (branch) Updated: date" # Create clickable link for just the PR number @@ -1780,6 +1811,7 @@ def display_pr_list(prs, color="white"): cout("{} ", clickable_number) cout("{} ", pr["title"], fg="white") cout("({}) ", pr["headRefName"], fg="gray") + cout("by {} ", pr["author"]["login"], fg="gray") cout("Updated: {}\n", pr["updatedAt"][:10], fg="gray") else: # Full format with clickable PR number @@ -1788,6 +1820,9 @@ def display_pr_list(prs, color="white"): cout("{} ", clickable_number) cout("{}\n", pr["title"], fg=color) cout(" {} -> {}\n", pr["headRefName"], pr["baseRefName"], fg="gray") + cout(" Author: {}\n", pr["author"]["login"], fg="gray") + 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") @@ -1795,10 +1830,7 @@ def display_pr_list(prs, color="white"): if waiting_on_me: cout("Your PRs - Waiting on You:\n", fg="red") display_pr_list(waiting_on_me, "white") - if args.compact: - cout("\n") - else: - cout("\n") + cout("\n") if waiting_on_review: cout("Your PRs - Waiting on Review:\n", fg="yellow") @@ -1811,10 +1843,7 @@ def display_pr_list(prs, color="white"): if approved: cout("Your PRs - Approved:\n", fg="green") display_pr_list(approved, "white") - if args.compact: - cout("\n") - else: - cout("\n") + cout("\n") if not my_prs_data: cout("No active pull requests authored by you.\n", fg="green") @@ -1823,6 +1852,8 @@ def display_pr_list(prs, color="white"): if review_prs_data: cout("Pull Requests Awaiting Your Review:\n", fg="yellow") for pr in review_prs_data: + check_text, check_color = get_check_status(pr) + if args.compact: # Compact format with only PR number clickable: "#123 Title (branch) Updated: date" # Create clickable link for just the PR number @@ -1841,6 +1872,8 @@ def display_pr_list(prs, color="white"): cout("{}\n", pr["title"], fg="white") cout(" {} -> {}\n", pr["headRefName"], pr["baseRefName"], fg="gray") cout(" Author: {}\n", pr["author"]["login"], fg="gray") + 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") else: From 243014f569023f8f7263ad4b972bab1bba3da544 Mon Sep 17 00:00:00 2001 From: Yashwanth Nannapaneni Date: Wed, 4 Jun 2025 11:07:17 -0700 Subject: [PATCH 07/26] Cleaning up and simplifying stacky inbox code (#7) Co-authored-by: Yashwanth Nannapaneni --- src/stacky/stacky.py | 103 ++++++++++++++++++++----------------------- 1 file changed, 48 insertions(+), 55 deletions(-) diff --git a/src/stacky/stacky.py b/src/stacky/stacky.py index 2f8e283..caa91d4 100755 --- a/src/stacky/stacky.py +++ b/src/stacky/stacky.py @@ -1799,50 +1799,67 @@ def get_check_status(pr): else: return f"Checks mixed", "yellow" - def display_pr_list(prs, color="white"): + 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 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 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: - check_text, check_color = get_check_status(pr) - if args.compact: - # Compact format with only PR number clickable: "#123 Title (branch) Updated: date" - # Create clickable link for just the 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") - cout("by {} ", pr["author"]["login"], fg="gray") - cout("Updated: {}\n", pr["updatedAt"][:10], fg="gray") + display_pr_compact(pr, show_author) else: - # Full format with clickable 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=color) - cout(" {} -> {}\n", pr["headRefName"], pr["baseRefName"], fg="gray") - cout(" Author: {}\n", pr["author"]["login"], fg="gray") - 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") + 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, "white") + 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, "white") - if args.compact: - cout("\n") - else: - cout("\n") + display_pr_list(waiting_on_review) + cout("\n") if approved: cout("Your PRs - Approved:\n", fg="green") - display_pr_list(approved, "white") + display_pr_list(approved) cout("\n") if not my_prs_data: @@ -1851,31 +1868,7 @@ def display_pr_list(prs, color="white"): # Display PRs waiting for review if review_prs_data: cout("Pull Requests Awaiting Your Review:\n", fg="yellow") - for pr in review_prs_data: - check_text, check_color = get_check_status(pr) - - if args.compact: - # Compact format with only PR number clickable: "#123 Title (branch) Updated: date" - # Create clickable link for just the 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") - cout("by {} ", pr["author"]["login"], fg="gray") - cout("Updated: {}\n", pr["updatedAt"][:10], fg="gray") - else: - # Full format with clickable 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") - cout(" Author: {}\n", pr["author"]["login"], fg="gray") - 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") + display_pr_list(review_prs_data, show_author=True) else: cout("No pull requests awaiting your review.\n", fg="yellow") From b3c77a375abd1301ff476acf3d59725bf18b199c Mon Sep 17 00:00:00 2001 From: Yashwanth Nannapaneni Date: Wed, 4 Jun 2025 15:32:19 -0700 Subject: [PATCH 08/26] Adding --no-verify flag to skip precommit hooks (#8) Co-authored-by: Yashwanth Nannapaneni --- src/stacky/stacky.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/stacky/stacky.py b/src/stacky/stacky.py index caa91d4..ed022e4 100755 --- a/src/stacky/stacky.py +++ b/src/stacky/stacky.py @@ -796,6 +796,7 @@ def cmd_branch_commit(stack: StackBranchSet, args): allow_empty=False, edit=True, add_all=args.add_all, + no_verify=args.no_verify, ) @@ -1198,7 +1199,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, add_all=False): +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) @@ -1220,6 +1221,8 @@ def do_commit(stack: StackBranchSet, *, message=None, amend=False, allow_empty=F cmd += ["-a"] if allow_empty: cmd += ["--allow-empty"] + if no_verify: + cmd += ["--no-verify"] if amend: cmd += ["--amend"] if not edit: @@ -1243,11 +1246,12 @@ def cmd_commit(stack: StackBranchSet, args): 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): @@ -1920,10 +1924,12 @@ def main(): 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 @@ -1943,6 +1949,7 @@ def main(): 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") From 18f1f9ef9a68d8089d4ed85204aef74a97811de9 Mon Sep 17 00:00:00 2001 From: Yashwanth Nannapaneni Date: Thu, 5 Jun 2025 11:46:52 -0700 Subject: [PATCH 09/26] Adding stacky fold command to fold a branch into parent (#9) Adding a command to allow folding the current branch into it's parent branch. All children branches of my current branch will become children of the parent Also fixing issue where use_merge was overwritten --------- Co-authored-by: Yashwanth Nannapaneni --- README.md | 13 +- src/stacky/stacky.py | 280 ++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 284 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 4d4b7b9..e46093b 100644 --- a/README.md +++ b/README.md @@ -42,7 +42,8 @@ Syntax is as follows: - `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 @@ -58,12 +59,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,inbox} ... + {continue,info,commit,amend,branch,b,stack,s,upstack,us,downstack,ds,update,import,adopt,land,push,sync,checkout,co,sco,inbox,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,inbox} + {continue,info,commit,amend,branch,b,stack,s,upstack,us,downstack,ds,update,import,adopt,land,push,sync,checkout,co,sco,inbox,fold} continue Continue previously interrupted command info Stack info commit Commit @@ -81,6 +82,7 @@ positional arguments: checkout (co) Checkout a branch sco Checkout a branch in this stack inbox List all active GitHub pull requests for the current user + fold Fold current branch into parent branch and delete current branch optional arguments: -h, --help show this help message and exit @@ -170,6 +172,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: @@ -179,6 +182,10 @@ List of parameters for each sections: * 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. +### 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. + ## License - [MIT License](https://github.com/rockset/stacky/blob/master/LICENSE.txt) diff --git a/src/stacky/stacky.py b/src/stacky/stacky.py index ed022e4..088fdee 100755 --- a/src/stacky/stacky.py +++ b/src/stacky/stacky.py @@ -120,7 +120,7 @@ def read_one_config(self, config_path: str): 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_force_push = bool(rawconfig.get("GIT", "use_force_push", fallback=self.use_force_push)) CONFIG: Optional[StackyConfig] = None @@ -1140,9 +1140,17 @@ 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 + # 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 get_commits_between_branches(a: BranchName, b: BranchName, *, no_merges: bool = False): + cmd = ["git", "log", "{}..{}".format(a, b), "--pretty=format:%H"] + if no_merges: + cmd.append("--no-merges") + lines = run_multiline(CmdArgs(cmd)) + assert lines is not None return [x.strip() for x in lines.split("\n")] - def inner_do_sync(syncs: List[StackBranch], sync_names: List[BranchName]): print() sync_type = "merge" if get_config().use_merge else "rebase" @@ -2069,6 +2077,11 @@ def main(): inbox_parser.add_argument("--compact", "-c", action="store_true", help="Show compact view") inbox_parser.set_defaults(func=cmd_inbox) + # 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) + args = parser.parse_args() logging.basicConfig(format=_LOGGING_FORMAT, level=LOGLEVELS[args.log_level], force=True) @@ -2099,10 +2112,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` @@ -2128,5 +2164,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_branches 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() From 6ba8370e1a21d655fab94c2fe66a6444726c8317 Mon Sep 17 00:00:00 2001 From: Yashwanth Nannapaneni Date: Thu, 5 Jun 2025 11:48:54 -0700 Subject: [PATCH 10/26] Cleaning up get_commits_between_branches (#10) Co-authored-by: Yashwanth Nannapaneni --- src/stacky/stacky.py | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/src/stacky/stacky.py b/src/stacky/stacky.py index 088fdee..608b0dc 100755 --- a/src/stacky/stacky.py +++ b/src/stacky/stacky.py @@ -1143,14 +1143,6 @@ def get_commits_between(a: Commit, b: Commit): # 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 get_commits_between_branches(a: BranchName, b: BranchName, *, no_merges: bool = False): - cmd = ["git", "log", "{}..{}".format(a, b), "--pretty=format:%H"] - if no_merges: - cmd.append("--no-merges") - lines = run_multiline(CmdArgs(cmd)) - assert lines is not None - return [x.strip() for x in lines.split("\n")] - def inner_do_sync(syncs: List[StackBranch], sync_names: List[BranchName]): print() sync_type = "merge" if get_config().use_merge else "rebase" @@ -2211,7 +2203,7 @@ def cmd_fold(stack: StackBranchSet, args): else: # Cherry-pick approach: apply individual commits if commits_to_apply: - # Reverse the list since get_commits_between_branches returns newest first + # 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) From 52bda486f9b69bac36f9c582939e1312653e6fdf Mon Sep 17 00:00:00 2001 From: Yashwanth Nannapaneni Date: Thu, 5 Jun 2025 12:34:03 -0700 Subject: [PATCH 11/26] Stack update will clean up deleted branch refs (#11) Co-authored-by: Yashwanth Nannapaneni --- src/stacky/stacky.py | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/src/stacky/stacky.py b/src/stacky/stacky.py index 608b0dc..25c4d70 100755 --- a/src/stacky/stacky.py +++ b/src/stacky/stacky.py @@ -1471,19 +1471,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])) @@ -1525,6 +1534,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) From 2039e19137f270efad7672f4c328b5afd8dd916d Mon Sep 17 00:00:00 2001 From: Yashwanth Nannapaneni Date: Thu, 5 Jun 2025 22:27:02 -0700 Subject: [PATCH 12/26] Making stacky inbox track draft PRs (#12) Co-authored-by: Yashwanth Nannapaneni --- src/stacky/stacky.py | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/src/stacky/stacky.py b/src/stacky/stacky.py index 25c4d70..a73103f 100755 --- a/src/stacky/stacky.py +++ b/src/stacky/stacky.py @@ -1726,7 +1726,8 @@ def cmd_inbox(stack: StackBranchSet, args): "reviewRequests", "mergeable", "mergeStateStatus", - "statusCheckRollup" + "statusCheckRollup", + "isDraft" ] # Get all open PRs authored by the current user @@ -1773,7 +1774,10 @@ def cmd_inbox(stack: StackBranchSet, args): approved = [] for pr in my_prs_data: - if pr["reviewDecision"] == "APPROVED": + 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) @@ -1827,6 +1831,9 @@ def display_pr_compact(pr, show_author=False): 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) @@ -1846,6 +1853,9 @@ def display_pr_full(pr, show_author=False): 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) From a31d30997e9699b5fcbeafdf82166279c47ba173 Mon Sep 17 00:00:00 2001 From: Yashwanth Nannapaneni Date: Tue, 17 Jun 2025 12:59:00 -0700 Subject: [PATCH 13/26] Adding new command stacky prs to interactive modify PR description (#13) Adding new command stacky pr, which will list all of the current users PRs and let you interactively update descriptions Co-authored-by: Yashwanth Nannapaneni --- README.md | 6 +- src/stacky/stacky.py | 157 ++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 160 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index e46093b..6815489 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,7 @@ Stacky doesn't use any git or Github APIs. It expects `git` and `gh` cli command 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 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`) @@ -59,12 +60,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,inbox,fold} ... + {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,inbox,fold} + {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 @@ -82,6 +83,7 @@ positional arguments: 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: diff --git a/src/stacky/stacky.py b/src/stacky/stacky.py index a73103f..1efb435 100755 --- a/src/stacky/stacky.py +++ b/src/stacky/stacky.py @@ -1710,6 +1710,68 @@ 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 = [ @@ -1727,7 +1789,8 @@ def cmd_inbox(stack: StackBranchSet, args): "mergeable", "mergeStateStatus", "statusCheckRollup", - "isDraft" + "isDraft", + "body" ] # Get all open PRs authored by the current user @@ -1897,6 +1960,94 @@ def display_pr_list(prs, show_author=False): 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: @@ -2089,6 +2240,10 @@ def main(): 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") From 59124c50dbdf615221aa4043456df98d78121305 Mon Sep 17 00:00:00 2001 From: Yashwanth Nannapaneni Date: Mon, 30 Jun 2025 14:55:55 -0700 Subject: [PATCH 14/26] Adding autocomplete options for stacky branch (#14) --- BUILD.bazel | 1 + README.md | 37 ++++++++++++++++++++++++++++++++++++- src/stacky/stacky.py | 22 +++++++++++++++++----- 3 files changed, 54 insertions(+), 6 deletions(-) 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 6815489..7325174 100644 --- a/README.md +++ b/README.md @@ -20,12 +20,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/). diff --git a/src/stacky/stacky.py b/src/stacky/stacky.py index 1efb435..31719ec 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 @@ -265,6 +267,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 @@ -2124,7 +2135,7 @@ def main(): 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 @@ -2169,7 +2180,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 @@ -2200,12 +2211,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 @@ -2229,7 +2240,7 @@ 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") @@ -2249,6 +2260,7 @@ def main(): 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) From e36b2c37f10f8aa5c9767b12ff3c5f96c16da09b Mon Sep 17 00:00:00 2001 From: Yashwanth Nannapaneni Date: Mon, 30 Jun 2025 15:24:39 -0700 Subject: [PATCH 15/26] updating readme directions to locally instally using pip (#15) Co-authored-by: Yashwanth Nannapaneni --- README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 7325174..15425ce 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 From afbf290687ad3a2ee4f8538d154005dafb2883aa Mon Sep 17 00:00:00 2001 From: Yashwanth Nannapaneni Date: Thu, 3 Jul 2025 14:53:27 -0700 Subject: [PATCH 16/26] Adding stacky comment when pushing up the PR (#16) Adding a comment to the pr description showing the current stack Co-authored-by: Yashwanth Nannapaneni --- src/stacky/stacky.py | 117 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 117 insertions(+) diff --git a/src/stacky/stacky.py b/src/stacky/stacky.py index 31719ec..ec5dac2 100755 --- a/src/stacky/stacky.py +++ b/src/stacky/stacky.py @@ -966,6 +966,117 @@ def create_gh_pr(b: StackBranch, prefix: str): ) +def generate_stack_string(forest: BranchesTreeForest) -> str: + """Generate a string representation of the PR stack""" + stack_lines = [] + + def add_branch_to_stack(b: StackBranch, depth: int): + if not b.parent or b.name in STACK_BOTTOMS: + return + + indent = " " * depth + pr_info = "" + if b.open_pr_info: + pr_info = f" (#{b.open_pr_info['number']})" + + stack_lines.append(f"{indent}- {b.name}{pr_info}") + + 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 add_or_update_stack_comment(branch: StackBranch, forest: BranchesTreeForest): + """Add or update stack comment in PR body""" + 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(forest) + + 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, *, @@ -1091,6 +1202,12 @@ def do_push( ) elif pr_action == PR_CREATE: create_gh_pr(b, prefix) + + # Handle stack comments for PRs + if pr: + for b in forest_depth_first(forest): + if b.open_pr_info: + add_or_update_stack_comment(b, forest) stop_muxed_ssh(remote_name) From b5dc6774e591c35dec750b1ad7ef86d052680833 Mon Sep 17 00:00:00 2001 From: Yashwanth Nannapaneni Date: Tue, 8 Jul 2025 11:19:59 -0700 Subject: [PATCH 17/26] Include stacky stack comment on the top most PR of a stack as well (#18) Co-authored-by: Yashwanth Nannapaneni --- src/stacky/stacky.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/stacky/stacky.py b/src/stacky/stacky.py index ec5dac2..8fa7e80 100755 --- a/src/stacky/stacky.py +++ b/src/stacky/stacky.py @@ -971,7 +971,7 @@ def generate_stack_string(forest: BranchesTreeForest) -> str: stack_lines = [] def add_branch_to_stack(b: StackBranch, depth: int): - if not b.parent or b.name in STACK_BOTTOMS: + if b.name in STACK_BOTTOMS: return indent = " " * depth From 846c21cf9757b64c8f47d795ced387cd81c50518 Mon Sep 17 00:00:00 2001 From: Yashwanth Nannapaneni Date: Thu, 17 Jul 2025 15:26:34 -0700 Subject: [PATCH 18/26] Adding a stack log command that shows the git log similarly whether use_merge or not (#19) Co-authored-by: Yashwanth Nannapaneni --- src/stacky/stacky.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/stacky/stacky.py b/src/stacky/stacky.py index 8fa7e80..e12539e 100755 --- a/src/stacky/stacky.py +++ b/src/stacky/stacky.py @@ -732,6 +732,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) @@ -2216,6 +2224,10 @@ 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") From b0bd7e1f20167136bf39c5083409655cf1df3938 Mon Sep 17 00:00:00 2001 From: Yashwanth Nannapaneni Date: Tue, 22 Jul 2025 16:33:18 -0700 Subject: [PATCH 19/26] Stacky comment shows current pr in the comment (#21) Co-authored-by: Yashwanth Nannapaneni --- src/stacky/stacky.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/stacky/stacky.py b/src/stacky/stacky.py index e12539e..214e13a 100755 --- a/src/stacky/stacky.py +++ b/src/stacky/stacky.py @@ -974,7 +974,7 @@ def create_gh_pr(b: StackBranch, prefix: str): ) -def generate_stack_string(forest: BranchesTreeForest) -> str: +def generate_stack_string(forest: BranchesTreeForest, current_branch: StackBranch) -> str: """Generate a string representation of the PR stack""" stack_lines = [] @@ -987,7 +987,10 @@ def add_branch_to_stack(b: StackBranch, depth: int): if b.open_pr_info: pr_info = f" (#{b.open_pr_info['number']})" - stack_lines.append(f"{indent}- {b.name}{pr_info}") + # 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(): @@ -1051,7 +1054,7 @@ def add_or_update_stack_comment(branch: StackBranch, forest: BranchesTreeForest) ) current_body = pr_data.get("body", "") - stack_string = generate_stack_string(forest) + stack_string = generate_stack_string(forest, branch) if not stack_string: return From 7c5db6138ef52c056b891f1197071c8ae0fb6fe0 Mon Sep 17 00:00:00 2001 From: Roopak Venkatakrishnan Date: Tue, 22 Jul 2025 21:21:57 -0700 Subject: [PATCH 20/26] chore: confirm - or y (#20) --- src/stacky/stacky.py | 232 +++++++++++++++++++++---------------------- 1 file changed, 116 insertions(+), 116 deletions(-) diff --git a/src/stacky/stacky.py b/src/stacky/stacky.py index 214e13a..ab41d65 100755 --- a/src/stacky/stacky.py +++ b/src/stacky/stacky.py @@ -138,7 +138,7 @@ def get_config() -> StackyConfig: def read_config() -> StackyConfig: config = StackyConfig() config_paths = [os.path.expanduser("~/.stackyconfig")] - + try: root_dir = get_top_level_dir() config_paths.append(f"{root_dir}/.stackyconfig") @@ -793,20 +793,20 @@ def cmd_branch_new(stack: StackBranchSet, args): 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, @@ -865,7 +865,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") @@ -977,11 +977,11 @@ 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: @@ -996,13 +996,13 @@ 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:**", @@ -1025,12 +1025,12 @@ 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 "" @@ -1040,34 +1040,34 @@ def add_or_update_stack_comment(branch: StackBranch, forest: BranchesTreeForest) """Add or update stack comment in PR body""" 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), + "gh", "pr", "view", str(pr_number), "--json", "body" ]) ) ) - + current_body = pr_data.get("body", "") stack_string = generate_stack_string(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), @@ -1078,7 +1078,7 @@ def add_or_update_stack_comment(branch: StackBranch, forest: BranchesTreeForest) 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), @@ -1213,7 +1213,7 @@ def do_push( ) elif pr_action == PR_CREATE: create_gh_pr(b, prefix) - + # Handle stack comments for PRs if pr: for b in forest_depth_first(forest): @@ -1610,10 +1610,10 @@ 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: @@ -1852,7 +1852,7 @@ def cmd_land(stack: StackBranchSet, args): 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", "") @@ -1860,47 +1860,47 @@ def edit_pr_description(pr): 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: @@ -1915,7 +1915,7 @@ def cmd_inbox(stack: StackBranchSet, args): """List all active GitHub pull requests for the current user""" fields = [ "number", - "title", + "title", "headRefName", "baseRefName", "state", @@ -1931,7 +1931,7 @@ def cmd_inbox(stack: StackBranchSet, args): "isDraft", "body" ] - + # Get all open PRs authored by the current user my_prs_data = json.loads( run_always_return( @@ -1950,7 +1950,7 @@ def cmd_inbox(stack: StackBranchSet, args): ) ) ) - + # Get all open PRs where current user is requested as reviewer review_prs_data = json.loads( run_always_return( @@ -1969,12 +1969,12 @@ def cmd_inbox(stack: StackBranchSet, args): ) ) ) - + # 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) @@ -1986,29 +1986,29 @@ def cmd_inbox(stack: StackBranchSet, args): 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" @@ -2018,52 +2018,52 @@ def get_check_status(pr): 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: @@ -2071,26 +2071,26 @@ def display_pr_list(prs, show_author=False): 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") @@ -2103,7 +2103,7 @@ def cmd_prs(stack: StackBranchSet, args): """Interactive PR management - select and edit PR descriptions""" fields = [ "number", - "title", + "title", "headRefName", "baseRefName", "state", @@ -2119,7 +2119,7 @@ def cmd_prs(stack: StackBranchSet, args): "isDraft", "body" ] - + # Get all open PRs authored by the current user my_prs_data = json.loads( run_always_return( @@ -2138,7 +2138,7 @@ def cmd_prs(stack: StackBranchSet, args): ) ) ) - + # Get all open PRs where current user is requested as reviewer review_prs_data = json.loads( run_always_return( @@ -2157,32 +2157,32 @@ def cmd_prs(stack: StackBranchSet, args): ) ) ) - + # 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) @@ -2478,43 +2478,43 @@ def main(): 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 @@ -2529,15 +2529,15 @@ def cmd_fold(stack: StackBranchSet, args): 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, +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({ @@ -2549,33 +2549,33 @@ def inner_do_merge_fold(stack: StackBranchSet, fold_branch_name: BranchName, par } }, 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, +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: @@ -2588,33 +2588,33 @@ def finish_merge_fold_operation(stack: StackBranchSet, fold_branch_name: BranchN # 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, +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({ @@ -2630,16 +2630,16 @@ def inner_do_fold(stack: StackBranchSet, fold_branch_name: BranchName, parent_br 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 @@ -2647,7 +2647,7 @@ def inner_do_fold(stack: StackBranchSet, fold_branch_name: BranchName, parent_br # 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: @@ -2656,28 +2656,28 @@ def inner_do_fold(stack: StackBranchSet, fold_branch_name: BranchName, parent_br 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, +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: @@ -2690,20 +2690,20 @@ def finish_fold_operation(stack: StackBranchSet, fold_branch_name: BranchName, # 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") From 334015231d23b6f2f8a59257d71c24c39f887835 Mon Sep 17 00:00:00 2001 From: Yashwanth Nannapaneni Date: Tue, 5 Aug 2025 14:07:47 -0700 Subject: [PATCH 21/26] Including entire forest for stacky comment (#22) Including the entire forest in stacky comment for a PR rather than just the linear path to the stack bottom --- src/stacky/stacky.py | 50 ++++++++++++++++++++++++++++++++++++++------ 1 file changed, 44 insertions(+), 6 deletions(-) diff --git a/src/stacky/stacky.py b/src/stacky/stacky.py index ab41d65..a64877c 100755 --- a/src/stacky/stacky.py +++ b/src/stacky/stacky.py @@ -1036,8 +1036,19 @@ def extract_stack_comment(body: str) -> str: return "" -def add_or_update_stack_comment(branch: StackBranch, forest: BranchesTreeForest): - """Add or update stack comment in PR body""" +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 @@ -1054,7 +1065,7 @@ def add_or_update_stack_comment(branch: StackBranch, forest: BranchesTreeForest) ) current_body = pr_data.get("body", "") - stack_string = generate_stack_string(forest, branch) + stack_string = generate_stack_string(complete_forest, branch) if not stack_string: return @@ -1088,6 +1099,8 @@ def add_or_update_stack_comment(branch: StackBranch, forest: BranchesTreeForest) cout("✓ Stack comment in PR #{} is already correct\n", pr_number, fg="green") + + def do_push( forest: BranchesTreeForest, *, @@ -1216,9 +1229,34 @@ def do_push( # Handle stack comments for PRs if pr: - for b in forest_depth_first(forest): - if b.open_pr_info: - add_or_update_stack_comment(b, forest) + # 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) From d09a8c3d586f6f07b0d9b8ef4331c933c41208af Mon Sep 17 00:00:00 2001 From: Yashwanth Nannapaneni Date: Tue, 5 Aug 2025 14:08:12 -0700 Subject: [PATCH 22/26] Stacky comment now includes PR status as of last push (#23) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Updated emoji logic match stacky inbox categorization: - 🚧 Construction for draft PRs (work in progress) - ✅ Check-mark for approved PRs - 🔄 Loading for PRs waiting on review (has pending review requests) - ❌ X for PRs waiting on author action (no pending reviews, likely needs changes) --- src/stacky/stacky.py | 25 ++++++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/src/stacky/stacky.py b/src/stacky/stacky.py index a64877c..e6eced0 100755 --- a/src/stacky/stacky.py +++ b/src/stacky/stacky.py @@ -331,6 +331,9 @@ def get_pr_info(branch: BranchName, *, full: bool = False) -> PRInfos: "title", "baseRefName", "headRefName", + "reviewDecision", + "reviewRequests", + "isDraft", ] if full: fields += ["commits"] @@ -985,7 +988,27 @@ def add_branch_to_stack(b: StackBranch, depth: int): indent = " " * depth pr_info = "" if b.open_pr_info: - pr_info = f" (#{b.open_pr_info['number']})" + pr_number = b.open_pr_info['number'] + + # Add approval status emoji using same logic as stacky inbox + review_decision = b.open_pr_info.get('reviewDecision') + review_requests = b.open_pr_info.get('reviewRequests', []) + is_draft = b.open_pr_info.get('isDraft', False) + + status_emoji = "" + if is_draft: + # Draft PRs are waiting on author + status_emoji = " 🚧" + elif review_decision == "APPROVED": + status_emoji = " ✅" + elif review_requests and len(review_requests) > 0: + # Has pending review requests - waiting on review + status_emoji = " 🔄" + else: + # No pending review requests, likely needs changes or author action + status_emoji = " ❌" + + 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 "" From c50e233352680922ac38d05e2b7d2f77bdbb910d Mon Sep 17 00:00:00 2001 From: Yashwanth Nannapaneni Date: Tue, 5 Aug 2025 16:16:53 -0700 Subject: [PATCH 23/26] Adding PR status emojis to stacky info --pr, also have a new UI option compact_pr_display to for stacky info --pr to be more compact (#24) Co-authored-by: Yashwanth Nannapaneni --- README.md | 1 + src/stacky/stacky.py | 69 +++++++++++++++++++++++++++----------------- 2 files changed, 44 insertions(+), 26 deletions(-) diff --git a/README.md b/README.md index 15425ce..975b74b 100644 --- a/README.md +++ b/README.md @@ -219,6 +219,7 @@ 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. ### 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. diff --git a/src/stacky/stacky.py b/src/stacky/stacky.py index e6eced0..5d40f3b 100755 --- a/src/stacky/stacky.py +++ b/src/stacky/stacky.py @@ -110,19 +110,21 @@ class StackyConfig: share_ssh_session: bool = False use_merge: bool = False use_force_push: bool = True + compact_pr_display: bool = False 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) if rawconfig.has_section("GIT"): - self.use_merge = bool(rawconfig.get("GIT", "use_merge", fallback=self.use_merge)) - self.use_force_push = 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 @@ -583,6 +585,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 @@ -602,9 +626,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 @@ -991,23 +1024,7 @@ def add_branch_to_stack(b: StackBranch, depth: int): pr_number = b.open_pr_info['number'] # Add approval status emoji using same logic as stacky inbox - review_decision = b.open_pr_info.get('reviewDecision') - review_requests = b.open_pr_info.get('reviewRequests', []) - is_draft = b.open_pr_info.get('isDraft', False) - - status_emoji = "" - if is_draft: - # Draft PRs are waiting on author - status_emoji = " 🚧" - elif review_decision == "APPROVED": - status_emoji = " ✅" - elif review_requests and len(review_requests) > 0: - # Has pending review requests - waiting on review - status_emoji = " 🔄" - else: - # No pending review requests, likely needs changes or author action - status_emoji = " ❌" - + 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) From 0de3e94232c3871bdce6c08eca22fdfd6a44064b Mon Sep 17 00:00:00 2001 From: Yashwanth Nannapaneni Date: Thu, 11 Sep 2025 14:17:30 -0700 Subject: [PATCH 24/26] Update readme with stacky config (#25) --- README.md | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/README.md b/README.md index 975b74b..0a1c0e1 100644 --- a/README.md +++ b/README.md @@ -225,6 +225,35 @@ List of parameters for each sections: * 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 + +[GIT] +# Use git merge instead of rebase for sync operations +use_merge = False + +# Allow force push when pushing branches +use_force_push = True +``` + ## License - [MIT License](https://github.com/rockset/stacky/blob/master/LICENSE.txt) From 45bfad46cecc2bbec02f2640cf6b65cab7004eb1 Mon Sep 17 00:00:00 2001 From: Yashwanth Nannapaneni Date: Wed, 22 Oct 2025 14:25:41 -0700 Subject: [PATCH 25/26] Making stacky pr comment toggleable (#26) Adding a new enable_stack_comment setting to enable/disable stacky stack comment --- src/stacky/stacky.py | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/src/stacky/stacky.py b/src/stacky/stacky.py index 5d40f3b..8119ac2 100755 --- a/src/stacky/stacky.py +++ b/src/stacky/stacky.py @@ -111,6 +111,7 @@ class StackyConfig: 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() @@ -121,6 +122,7 @@ def read_one_config(self, config_path: str): 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 = rawconfig.getboolean("GIT", "use_merge", fallback=self.use_merge) @@ -1236,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: @@ -1268,7 +1266,7 @@ def do_push( create_gh_pr(b, prefix) # Handle stack comments for PRs - if pr: + if pr and get_config().enable_stack_comment: # Reload PR info to include newly created PRs load_pr_info_for_forest(forest) From 71dc4cd6ba0929e07838b3b795da2bb8f2618d2e Mon Sep 17 00:00:00 2001 From: Yashwanth Nannapaneni Date: Wed, 22 Oct 2025 14:33:14 -0700 Subject: [PATCH 26/26] Updating readme with info on enable_stack_comment (#27) --- README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/README.md b/README.md index 0a1c0e1..8e68e53 100644 --- a/README.md +++ b/README.md @@ -220,6 +220,7 @@ List of parameters for each sections: * 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. @@ -246,6 +247,9 @@ 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