From 77a5d62a3ab3b1329bc554e0221e8789899f9978 Mon Sep 17 00:00:00 2001 From: Yashwanth Nannapaneni Date: Thu, 1 Aug 2024 13:56:18 -0700 Subject: [PATCH 01/28] 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/28] 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/28] 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/28] 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/28] 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/28] 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/28] 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/28] 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/28] 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/28] 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/28] 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/28] 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/28] 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/28] 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/28] 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/28] 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/28] 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/28] 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/28] 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/28] 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/28] 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/28] 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/28] 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/28] 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/28] 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/28] 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 From 972fbf40df32cbf11cf6dba8e985a77fb9eeda3f Mon Sep 17 00:00:00 2001 From: Yashwanth Nannapaneni Date: Mon, 12 Jan 2026 16:32:33 -0800 Subject: [PATCH 27/28] Updating __pycache__/ gitignore (#29) Co-authored-by: Yashwanth Nannapaneni --- .gitignore | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index d652e03..d8e4034 100644 --- a/.gitignore +++ b/.gitignore @@ -2,7 +2,7 @@ dist/ rockset_stacky.egg-info/ src/rockset_stacky.egg-info/ build/ -src/stacky/__pycache__ +__pycache__/ bazel-* .mypy_cache From 04c102591ba7a2993cb90ab8f467d2a7990d91cf Mon Sep 17 00:00:00 2001 From: Yashwanth Nannapaneni Date: Wed, 14 Jan 2026 10:29:37 -0800 Subject: [PATCH 28/28] Modularizing and adding tests (#28) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **Stack:** - yash/modularize (#28 ❌) ← (CURRENT PR) --- setup.py | 2 +- src/stacky/__init__.py | 35 +- src/stacky/commands/__init__.py | 1 + src/stacky/commands/branch.py | 57 + src/stacky/commands/commit.py | 70 + src/stacky/commands/downstack.py | 30 + src/stacky/commands/fold.py | 199 ++ src/stacky/commands/inbox.py | 171 + src/stacky/commands/land.py | 77 + src/stacky/commands/navigation.py | 66 + src/stacky/commands/stack.py | 39 + src/stacky/commands/update.py | 129 + src/stacky/commands/upstack.py | 76 + src/stacky/git/__init__.py | 1 + src/stacky/git/branch.py | 104 + src/stacky/git/refs.py | 107 + src/stacky/git/remote.py | 100 + src/stacky/main.py | 324 ++ src/stacky/pr/__init__.py | 1 + src/stacky/pr/github.py | 255 ++ src/stacky/stack/__init__.py | 1 + src/stacky/stack/models.py | 140 + src/stacky/stack/operations.py | 349 +++ src/stacky/stack/tree.py | 193 ++ src/stacky/stacky.py | 2787 ----------------- src/stacky/stacky_test.py | 180 -- src/stacky/tests/__init__.py | 0 .../test_integration.cpython-311.pyc | Bin 0 -> 10187 bytes src/stacky/tests/test_commands/__init__.py | 0 .../__pycache__/__init__.cpython-311.pyc | Bin 0 -> 177 bytes .../__pycache__/test_land.cpython-311.pyc | Bin 0 -> 7421 bytes src/stacky/tests/test_commands/test_land.py | 186 ++ src/stacky/tests/test_git/__init__.py | 0 .../__pycache__/__init__.cpython-311.pyc | Bin 0 -> 172 bytes .../__pycache__/test_branch.cpython-311.pyc | Bin 0 -> 8790 bytes .../__pycache__/test_refs.cpython-311.pyc | Bin 0 -> 9584 bytes .../__pycache__/test_remote.cpython-311.pyc | Bin 0 -> 6079 bytes src/stacky/tests/test_git/test_branch.py | 122 + src/stacky/tests/test_git/test_refs.py | 130 + src/stacky/tests/test_git/test_remote.py | 94 + src/stacky/tests/test_integration.py | 169 + src/stacky/tests/test_pr/__init__.py | 0 .../__pycache__/__init__.cpython-311.pyc | Bin 0 -> 171 bytes .../__pycache__/test_github.cpython-311.pyc | Bin 0 -> 11015 bytes src/stacky/tests/test_pr/test_github.py | 175 ++ src/stacky/tests/test_stack/__init__.py | 0 .../__pycache__/__init__.cpython-311.pyc | Bin 0 -> 174 bytes .../__pycache__/test_models.cpython-311.pyc | Bin 0 -> 9588 bytes .../__pycache__/test_tree.cpython-311.pyc | Bin 0 -> 8868 bytes src/stacky/tests/test_stack/test_models.py | 166 + src/stacky/tests/test_stack/test_tree.py | 137 + src/stacky/tests/test_utils/__init__.py | 0 .../__pycache__/__init__.cpython-311.pyc | Bin 0 -> 174 bytes .../__pycache__/test_config.cpython-311.pyc | Bin 0 -> 7622 bytes .../__pycache__/test_shell.cpython-311.pyc | Bin 0 -> 7442 bytes src/stacky/tests/test_utils/test_config.py | 104 + src/stacky/tests/test_utils/test_shell.py | 122 + src/stacky/utils/__init__.py | 1 + src/stacky/utils/config.py | 71 + src/stacky/utils/logging.py | 77 + src/stacky/utils/shell.py | 61 + src/stacky/utils/types.py | 39 + src/stacky/utils/ui.py | 94 + 63 files changed, 4273 insertions(+), 2969 deletions(-) create mode 100644 src/stacky/commands/__init__.py create mode 100644 src/stacky/commands/branch.py create mode 100644 src/stacky/commands/commit.py create mode 100644 src/stacky/commands/downstack.py create mode 100644 src/stacky/commands/fold.py create mode 100644 src/stacky/commands/inbox.py create mode 100644 src/stacky/commands/land.py create mode 100644 src/stacky/commands/navigation.py create mode 100644 src/stacky/commands/stack.py create mode 100644 src/stacky/commands/update.py create mode 100644 src/stacky/commands/upstack.py create mode 100644 src/stacky/git/__init__.py create mode 100644 src/stacky/git/branch.py create mode 100644 src/stacky/git/refs.py create mode 100644 src/stacky/git/remote.py create mode 100644 src/stacky/main.py create mode 100644 src/stacky/pr/__init__.py create mode 100644 src/stacky/pr/github.py create mode 100644 src/stacky/stack/__init__.py create mode 100644 src/stacky/stack/models.py create mode 100644 src/stacky/stack/operations.py create mode 100644 src/stacky/stack/tree.py delete mode 100755 src/stacky/stacky.py delete mode 100755 src/stacky/stacky_test.py create mode 100644 src/stacky/tests/__init__.py create mode 100644 src/stacky/tests/__pycache__/test_integration.cpython-311.pyc create mode 100644 src/stacky/tests/test_commands/__init__.py create mode 100644 src/stacky/tests/test_commands/__pycache__/__init__.cpython-311.pyc create mode 100644 src/stacky/tests/test_commands/__pycache__/test_land.cpython-311.pyc create mode 100644 src/stacky/tests/test_commands/test_land.py create mode 100644 src/stacky/tests/test_git/__init__.py create mode 100644 src/stacky/tests/test_git/__pycache__/__init__.cpython-311.pyc create mode 100644 src/stacky/tests/test_git/__pycache__/test_branch.cpython-311.pyc create mode 100644 src/stacky/tests/test_git/__pycache__/test_refs.cpython-311.pyc create mode 100644 src/stacky/tests/test_git/__pycache__/test_remote.cpython-311.pyc create mode 100644 src/stacky/tests/test_git/test_branch.py create mode 100644 src/stacky/tests/test_git/test_refs.py create mode 100644 src/stacky/tests/test_git/test_remote.py create mode 100644 src/stacky/tests/test_integration.py create mode 100644 src/stacky/tests/test_pr/__init__.py create mode 100644 src/stacky/tests/test_pr/__pycache__/__init__.cpython-311.pyc create mode 100644 src/stacky/tests/test_pr/__pycache__/test_github.cpython-311.pyc create mode 100644 src/stacky/tests/test_pr/test_github.py create mode 100644 src/stacky/tests/test_stack/__init__.py create mode 100644 src/stacky/tests/test_stack/__pycache__/__init__.cpython-311.pyc create mode 100644 src/stacky/tests/test_stack/__pycache__/test_models.cpython-311.pyc create mode 100644 src/stacky/tests/test_stack/__pycache__/test_tree.cpython-311.pyc create mode 100644 src/stacky/tests/test_stack/test_models.py create mode 100644 src/stacky/tests/test_stack/test_tree.py create mode 100644 src/stacky/tests/test_utils/__init__.py create mode 100644 src/stacky/tests/test_utils/__pycache__/__init__.cpython-311.pyc create mode 100644 src/stacky/tests/test_utils/__pycache__/test_config.cpython-311.pyc create mode 100644 src/stacky/tests/test_utils/__pycache__/test_shell.cpython-311.pyc create mode 100644 src/stacky/tests/test_utils/test_config.py create mode 100644 src/stacky/tests/test_utils/test_shell.py create mode 100644 src/stacky/utils/__init__.py create mode 100644 src/stacky/utils/config.py create mode 100644 src/stacky/utils/logging.py create mode 100644 src/stacky/utils/shell.py create mode 100644 src/stacky/utils/types.py create mode 100644 src/stacky/utils/ui.py diff --git a/setup.py b/setup.py index 675b840..644eccc 100644 --- a/setup.py +++ b/setup.py @@ -23,7 +23,7 @@ package_dir={"": "src"}, packages=find_packages(where="src"), python_requires=">=3.8, <4", - install_requires=["asciitree", "ansicolors", "simple-term-menu"], + install_requires=["asciitree", "ansicolors", "simple-term-menu", "argcomplete"], entry_points={ "console_scripts": [ "stacky=stacky:main", diff --git a/src/stacky/__init__.py b/src/stacky/__init__.py index 1ba03f4..cce717e 100644 --- a/src/stacky/__init__.py +++ b/src/stacky/__init__.py @@ -1,4 +1,37 @@ -from .stacky import main +"""Stacky - GitHub helper for stacked diffs.""" + +from .main import main + +# Re-exports for backward compatibility with tests +from .utils.shell import _check_returncode, run, run_always_return, run_multiline +from .utils.logging import ( + die, cout, debug, info, warning, error, fmt, + COLOR_STDOUT, COLOR_STDERR, ExitException +) +from .utils.types import BranchName, Commit, CmdArgs, STACK_BOTTOMS +from .utils.config import StackyConfig, get_config, read_config +from .utils.ui import confirm, prompt + +from .git.branch import ( + get_current_branch, get_all_branches, get_top_level_dir, + get_stack_parent_branch, checkout, create_branch +) +from .git.remote import ( + get_remote_info, get_remote_type, gen_ssh_mux_cmd, + start_muxed_ssh, stop_muxed_ssh +) +from .git.refs import get_stack_parent_commit, set_parent_commit, get_commit + +from .stack.models import PRInfo, PRInfos, StackBranch, StackBranchSet +from .stack.tree import ( + get_all_stacks_as_forest, get_current_stack_as_forest, + get_current_downstack_as_forest, get_current_upstack_as_forest, + print_tree, print_forest, format_tree +) + +from .pr.github import find_issue_marker, get_pr_info, create_gh_pr + +from .commands.land import cmd_land def runner(): diff --git a/src/stacky/commands/__init__.py b/src/stacky/commands/__init__.py new file mode 100644 index 0000000..e630d94 --- /dev/null +++ b/src/stacky/commands/__init__.py @@ -0,0 +1 @@ +# Commands module - command handlers for stacky diff --git a/src/stacky/commands/branch.py b/src/stacky/commands/branch.py new file mode 100644 index 0000000..99418df --- /dev/null +++ b/src/stacky/commands/branch.py @@ -0,0 +1,57 @@ +"""Branch commands - new, commit, checkout.""" + +from stacky.commands.commit import do_commit +from stacky.git.branch import checkout, create_branch, get_current_branch_name, set_current_branch +from stacky.git.refs import get_commit +from stacky.stack.models import StackBranchSet +from stacky.stack.operations import load_stack_for_given_branch +from stacky.stack.tree import get_all_stacks_as_forest +from stacky.utils.shell import run +from stacky.utils.types import BranchName, CmdArgs +from stacky.utils.ui import menu_choose_branch + + +def cmd_branch_new(stack: StackBranchSet, args): + """Create a new branch on top of the current branch.""" + current_branch = get_current_branch_name() + 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, ""])) + + +def cmd_branch_commit(stack: StackBranchSet, args): + """Create a new branch and commit all changes with the provided message.""" + current_branch = get_current_branch_name() + 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 + set_current_branch(BranchName(name)) + + # Reload the stack to include the new branch + load_stack_for_given_branch(stack, BranchName(name)) + + # Now commit all changes with the provided message + do_commit( + stack, + message=args.message, + amend=False, + allow_empty=False, + edit=True, + add_all=args.add_all, + no_verify=args.no_verify, + ) + + +def cmd_branch_checkout(stack: StackBranchSet, args): + """Checkout a branch (with menu if no name provided).""" + branch_name = args.name + if branch_name is None: + forest = get_all_stacks_as_forest(stack) + branch_name = menu_choose_branch(forest).name + checkout(branch_name) diff --git a/src/stacky/commands/commit.py b/src/stacky/commands/commit.py new file mode 100644 index 0000000..7a25eee --- /dev/null +++ b/src/stacky/commands/commit.py @@ -0,0 +1,70 @@ +"""Commit commands - commit, amend.""" + +from stacky.git.branch import get_current_branch_name +from stacky.git.refs import get_commit +from stacky.stack.models import StackBranchSet +from stacky.stack.operations import do_sync +from stacky.stack.tree import get_current_upstack_as_forest +from stacky.utils.config import get_config +from stacky.utils.logging import die +from stacky.utils.shell import run +from stacky.utils.types import CmdArgs + + +def do_commit(stack: StackBranchSet, *, message=None, amend=False, allow_empty=False, + edit=True, add_all=False, no_verify=False): + """Perform a commit operation.""" + current_branch = get_current_branch_name() + b = stack.stack[current_branch] + if not b.parent: + die("Do not commit directly on {}", b.name) + if not b.is_synced_with_parent(): + die( + "Branch {} is not synced with parent {}, sync before committing", + 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) + + cmd = ["git", "commit"] + if add_all: + cmd += ["-a"] + if allow_empty: + cmd += ["--allow-empty"] + if no_verify: + cmd += ["--no-verify"] + if amend: + cmd += ["--amend"] + if not edit: + cmd += ["--no-edit"] + elif not edit: + die("--no-edit is only supported with --amend") + if message: + cmd += ["-m", message] + run(CmdArgs(cmd), out=True) + + # Sync everything upstack + b.commit = get_commit(b.name) + do_sync(get_current_upstack_as_forest(stack)) + + +def cmd_commit(stack: StackBranchSet, args): + """Commit command handler.""" + do_commit( + stack, + message=args.message, + amend=args.amend, + allow_empty=args.allow_empty, + edit=not args.no_edit, + add_all=args.add_all, + no_verify=args.no_verify, + ) + + +def cmd_amend(stack: StackBranchSet, args): + """Amend last commit (shortcut).""" + do_commit(stack, amend=True, edit=False, no_verify=args.no_verify) diff --git a/src/stacky/commands/downstack.py b/src/stacky/commands/downstack.py new file mode 100644 index 0000000..dd1d5f4 --- /dev/null +++ b/src/stacky/commands/downstack.py @@ -0,0 +1,30 @@ +"""Downstack commands - info, push, sync.""" + +from stacky.stack.models import StackBranchSet +from stacky.stack.operations import do_push, do_sync +from stacky.stack.tree import ( + get_current_downstack_as_forest, load_pr_info_for_forest, print_forest +) + + +def cmd_downstack_info(stack: StackBranchSet, args): + """Show info for current downstack.""" + forest = get_current_downstack_as_forest(stack) + if args.pr: + load_pr_info_for_forest(forest) + print_forest(forest) + + +def cmd_downstack_push(stack: StackBranchSet, args): + """Push current downstack.""" + do_push( + get_current_downstack_as_forest(stack), + force=args.force, + pr=args.pr, + remote_name=args.remote_name, + ) + + +def cmd_downstack_sync(stack: StackBranchSet, args): + """Sync current downstack.""" + do_sync(get_current_downstack_as_forest(stack)) diff --git a/src/stacky/commands/fold.py b/src/stacky/commands/fold.py new file mode 100644 index 0000000..e5b93dc --- /dev/null +++ b/src/stacky/commands/fold.py @@ -0,0 +1,199 @@ +"""Fold command - fold branch into parent.""" + +import json +import os +from typing import List + +from stacky.git.branch import checkout, get_current_branch_name, set_current_branch +from stacky.git.refs import get_commit, get_commits_between, set_parent, set_parent_commit +from stacky.stack.models import StackBranch, StackBranchSet +from stacky.utils.config import get_config +from stacky.utils.logging import cout, die, info +from stacky.utils.shell import run +from stacky.utils.types import BranchName, CmdArgs, STACK_BOTTOMS, STATE_FILE, TMP_STATE_FILE + + +def cmd_fold(stack: StackBranchSet, args): + """Fold current branch into parent branch and delete current branch.""" + current_branch = get_current_branch_name() + + 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, + ) + + 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") + + 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") + + checkout(b.parent.name) + set_current_branch(b.parent.name) + + if get_config().use_merge: + inner_do_merge_fold(stack, b.name, b.parent.name, [child.name for child in children]) + else: + if commits_to_apply: + commits_to_apply = list(reversed(commits_to_apply)) + inner_do_fold(stack, b.name, b.parent.name, commits_to_apply, [child.name for child in children], args.allow_empty) + else: + finish_fold_operation(stack, b.name, b.parent.name, [child.name for child in children]) + + +def inner_do_merge_fold(stack: StackBranchSet, fold_branch_name: BranchName, parent_branch_name: BranchName, + children_names: List[BranchName]): + """Perform merge-based fold operation.""" + print() + current_branch = get_current_branch_name() + + 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) + + 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) + + 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 merge-based fold operation.""" + fold_branch = stack.stack.get(fold_branch_name) + parent_branch = stack.stack[parent_branch_name] + + if not fold_branch: + cout("✓ Merge fold operation completed\n", fg="green") + return + + parent_branch.commit = get_commit(parent_branch_name) + + 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) + set_parent_commit(child.name, parent_branch.commit, child.parent_commit) + child.parent_commit = parent_branch.commit + + parent_branch.children.discard(fold_branch) + + info("Deleting branch {}", fold_branch.name) + run(CmdArgs(["git", "branch", "-D", fold_branch.name])) + run(CmdArgs(["git", "update-ref", "-d", "refs/stack-parent/{}".format(fold_branch.name)])) + 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): + """Cherry-pick based fold operation.""" + print() + current_branch = get_current_branch_name() + + 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) + + commit = commits_to_apply.pop() + + # Check if commit would be empty + dry_run_result = run(CmdArgs(["git", "cherry-pick", "--no-commit", commit]), check=False) + if dry_run_result is not None: + has_changes = run(CmdArgs(["git", "diff", "--cached", "--quiet"]), check=False) is None + run(CmdArgs(["git", "reset", "--hard", "HEAD"])) + if not has_changes: + cout("Skipping empty commit {}\n", commit[:8], fg="yellow") + continue + else: + 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) + + 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 fold operation after commits applied.""" + fold_branch = stack.stack.get(fold_branch_name) + parent_branch = stack.stack[parent_branch_name] + + if not fold_branch: + cout("✓ Fold operation completed\n", fg="green") + return + + parent_branch.commit = get_commit(parent_branch_name) + + 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) + set_parent_commit(child.name, parent_branch.commit, child.parent_commit) + child.parent_commit = parent_branch.commit + + parent_branch.children.discard(fold_branch) + + info("Deleting branch {}", fold_branch.name) + run(CmdArgs(["git", "branch", "-D", fold_branch.name])) + run(CmdArgs(["git", "update-ref", "-d", "refs/stack-parent/{}".format(fold_branch.name)])) + stack.remove(fold_branch.name) + + cout("✓ Successfully folded {} into {}\n", fold_branch.name, parent_branch.name, fg="green") diff --git a/src/stacky/commands/inbox.py b/src/stacky/commands/inbox.py new file mode 100644 index 0000000..59e1b50 --- /dev/null +++ b/src/stacky/commands/inbox.py @@ -0,0 +1,171 @@ +"""Inbox commands - inbox, prs.""" + +import json + +from simple_term_menu import TerminalMenu # type: ignore + +from stacky.pr.github import edit_pr_description +from stacky.stack.models import StackBranchSet +from stacky.utils.logging import IS_TERMINAL, cout, die +from stacky.utils.shell import run_always_return +from stacky.utils.types import CmdArgs + + +def cmd_inbox(stack: StackBranchSet, args): + """List all active GitHub pull requests for the current user.""" + fields = [ + "number", "title", "headRefName", "baseRefName", "state", "url", + "createdAt", "updatedAt", "author", "reviewDecision", "reviewRequests", + "mergeable", "mergeStateStatus", "statusCheckRollup", "isDraft", "body" + ] + + my_prs_data = json.loads( + run_always_return(CmdArgs([ + "gh", "pr", "list", "--json", ",".join(fields), + "--state", "open", "--author", "@me" + ])) + ) + + review_prs_data = json.loads( + run_always_return(CmdArgs([ + "gh", "pr", "list", "--json", ",".join(fields), + "--state", "open", "--search", "review-requested:@me" + ])) + ) + + # Categorize PRs + waiting_on_me = [] + waiting_on_review = [] + approved = [] + + for pr in my_prs_data: + if pr.get("isDraft", False): + waiting_on_me.append(pr) + elif pr["reviewDecision"] == "APPROVED": + approved.append(pr) + elif pr["reviewRequests"] and len(pr["reviewRequests"]) > 0: + waiting_on_review.append(pr) + else: + waiting_on_me.append(pr) + + # Sort by updatedAt + for lst in [waiting_on_me, waiting_on_review, approved, review_prs_data]: + lst.sort(key=lambda pr: pr["updatedAt"], reverse=True) + + def get_check_status(pr): + if not pr.get("statusCheckRollup") or len(pr.get("statusCheckRollup")) == 0: + return "", "gray" + rollup = pr["statusCheckRollup"] + states = [check["state"] for check in rollup if isinstance(check, dict) and "state" in check] + if not states: + return "", "gray" + 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" + return "Checks mixed", "yellow" + + def display_pr_compact(pr, show_author=False): + check_text, check_color = get_check_status(pr) + 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): + check_text, check_color = get_check_status(pr) + 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): + for pr in prs: + if args.compact: + display_pr_compact(pr, show_author) + else: + display_pr_full(pr, show_author) + + 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") + if review_prs_data: + cout("Pull Requests Awaiting Your Review:\n", fg="yellow") + display_pr_list(review_prs_data, show_author=True) + else: + cout("No pull requests awaiting your review.\n", fg="yellow") + + +def cmd_prs(stack: StackBranchSet, args): + """Interactive PR management - select and edit PR descriptions.""" + fields = [ + "number", "title", "headRefName", "baseRefName", "state", "url", + "createdAt", "updatedAt", "author", "reviewDecision", "reviewRequests", + "mergeable", "mergeStateStatus", "statusCheckRollup", "isDraft", "body" + ] + + my_prs_data = json.loads( + run_always_return(CmdArgs([ + "gh", "pr", "list", "--json", ",".join(fields), + "--state", "open", "--author", "@me" + ])) + ) + + review_prs_data = json.loads( + run_always_return(CmdArgs([ + "gh", "pr", "list", "--json", ",".join(fields), + "--state", "open", "--search", "review-requested:@me" + ])) + ) + + 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") + + menu_options = [f"#{pr['number']} {pr['title']}" for pr in all_prs] + 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: + break + + selected_pr = all_prs[idx] + edit_pr_description(selected_pr) diff --git a/src/stacky/commands/land.py b/src/stacky/commands/land.py new file mode 100644 index 0000000..bf86d81 --- /dev/null +++ b/src/stacky/commands/land.py @@ -0,0 +1,77 @@ +"""Land command - land a PR.""" + +import sys + +from stacky.git.branch import get_current_branch_name +from stacky.stack.models import StackBranchSet +from stacky.stack.tree import get_current_downstack_as_forest +from stacky.utils.logging import COLOR_STDOUT, cout, die, fmt +from stacky.utils.shell import run +from stacky.utils.types import CmdArgs, Commit +from stacky.utils.ui import confirm + + +def cmd_land(stack: StackBranchSet, args): + """Land bottom-most PR on current stack.""" + current_branch = get_current_branch_name() + forest = get_current_downstack_as_forest(stack) + assert len(forest) == 1 + branches = [] + p = forest[0] + while p: + assert len(p) == 1 + _, (b, p) = next(iter(p.items())) + branches.append(b) + assert branches + assert branches[0] in stack.bottoms + if len(branches) == 1: + die("May not land {}", branches[0].name) + + b = branches[1] + if not b.is_synced_with_parent(): + die( + "Branch {} is not synced with parent {}, sync before landing", + b.name, b.parent.name, + ) + if not b.is_synced_with_remote(): + die( + "Branch {} is not synced with remote branch, push local changes before landing", + b.name, + ) + + b.load_pr_info() + pr = b.open_pr_info + if not pr: + die("Branch {} does not have an open PR", b.name) + assert pr is not None + + if pr["mergeable"] != "MERGEABLE": + die( + "PR #{} for branch {} is not mergeable: {}", + pr["number"], b.name, pr["mergeable"], + ) + + if len(branches) > 2: + cout( + "The `land` command only lands the bottom-most branch {}; " + "the current stack has {} branches, ending with {}\n", + b.name, len(branches) - 1, current_branch, fg="yellow", + ) + + msg = fmt("- Will land PR #{} (", pr["number"], color=COLOR_STDOUT) + msg += fmt("{}", pr["url"], color=COLOR_STDOUT, fg="blue") + msg += fmt(") for branch {}", b.name, color=COLOR_STDOUT) + msg += fmt(" into branch {}\n", b.parent.name, color=COLOR_STDOUT) + sys.stdout.write(msg) + + if not args.force: + confirm() + + v = run(CmdArgs(["git", "rev-parse", b.name])) + assert v is not None + head_commit = Commit(v) + cmd = CmdArgs(["gh", "pr", "merge", b.name, "--squash", "--match-head-commit", head_commit]) + if args.auto: + cmd.append("--auto") + run(cmd, out=True) + cout("\n✓ Success! Run `stacky update` to update local state.\n", fg="green") diff --git a/src/stacky/commands/navigation.py b/src/stacky/commands/navigation.py new file mode 100644 index 0000000..29b5a3e --- /dev/null +++ b/src/stacky/commands/navigation.py @@ -0,0 +1,66 @@ +"""Navigation commands - info, log, up, down.""" + +from stacky.git.branch import checkout, get_current_branch_name +from stacky.stack.models import StackBranchSet +from stacky.stack.tree import ( + get_all_stacks_as_forest, load_pr_info_for_forest, print_forest +) +from stacky.utils.config import get_config +from stacky.utils.logging import IS_TERMINAL, cout, die, info +from stacky.utils.shell import run +from stacky.utils.types import BranchesTreeForest, BranchName, BranchesTree +from stacky.utils.ui import menu_choose_branch + + +def cmd_info(stack: StackBranchSet, args): + """Show info for all stacks.""" + forest = get_all_stacks_as_forest(stack) + if args.pr: + load_pr_info_for_forest(forest) + print_forest(forest) + + +def cmd_log(stack: StackBranchSet, args): + """Show git log with conditional merge handling.""" + config = get_config() + if config.use_merge: + run(["git", "log", "--no-merges", "--first-parent"], out=True) + else: + run(["git", "log"], out=True) + + +def cmd_branch_up(stack: StackBranchSet, args): + """Move up in the stack (away from master/main).""" + current_branch = get_current_branch_name() + b = stack.stack[current_branch] + if not b.children: + info("Branch {} is already at the top of the stack", current_branch) + return + if len(b.children) > 1: + if not IS_TERMINAL: + die( + "Branch {} has multiple children: {}", + current_branch, ", ".join(c.name for c in b.children), + ) + cout( + "Branch {} has {} children, choose one\n", + current_branch, len(b.children), fg="green", + ) + forest = BranchesTreeForest([ + BranchesTree({BranchName(c.name): (c, BranchesTree({}))}) + for c in b.children + ]) + child = menu_choose_branch(forest).name + else: + child = next(iter(b.children)).name + checkout(child) + + +def cmd_branch_down(stack: StackBranchSet, args): + """Move down in the stack (towards master/main).""" + current_branch = get_current_branch_name() + b = stack.stack[current_branch] + if not b.parent: + info("Branch {} is already at the bottom of the stack", current_branch) + return + checkout(b.parent.name) diff --git a/src/stacky/commands/stack.py b/src/stacky/commands/stack.py new file mode 100644 index 0000000..bf49a58 --- /dev/null +++ b/src/stacky/commands/stack.py @@ -0,0 +1,39 @@ +"""Stack commands - stack info, push, sync, checkout.""" + +from stacky.git.branch import checkout +from stacky.stack.models import StackBranchSet +from stacky.stack.operations import do_push, do_sync +from stacky.stack.tree import ( + get_current_stack_as_forest, load_pr_info_for_forest, print_forest +) +from stacky.utils.ui import menu_choose_branch + + +def cmd_stack_info(stack: StackBranchSet, args): + """Show info for current stack.""" + forest = get_current_stack_as_forest(stack) + if args.pr: + load_pr_info_for_forest(forest) + print_forest(forest) + + +def cmd_stack_push(stack: StackBranchSet, args): + """Push current stack.""" + do_push( + get_current_stack_as_forest(stack), + force=args.force, + pr=args.pr, + remote_name=args.remote_name, + ) + + +def cmd_stack_sync(stack: StackBranchSet, args): + """Sync current stack.""" + do_sync(get_current_stack_as_forest(stack)) + + +def cmd_stack_checkout(stack: StackBranchSet, args): + """Checkout a branch in current stack.""" + forest = get_current_stack_as_forest(stack) + branch_name = menu_choose_branch(forest).name + checkout(branch_name) diff --git a/src/stacky/commands/update.py b/src/stacky/commands/update.py new file mode 100644 index 0000000..702e017 --- /dev/null +++ b/src/stacky/commands/update.py @@ -0,0 +1,129 @@ +"""Update commands - update, import, adopt.""" + +from stacky.git.branch import get_current_branch_name, get_real_stack_bottom, set_current_branch +from stacky.git.refs import get_merge_base, set_parent, set_parent_commit +from stacky.git.remote import start_muxed_ssh, stop_muxed_ssh +from stacky.pr.github import get_pr_info +from stacky.stack.models import StackBranch, StackBranchSet +from stacky.stack.operations import cleanup_unused_refs, delete_branches, get_branches_to_delete +from stacky.stack.tree import get_bottom_level_branches_as_forest, load_pr_info_for_forest +from stacky.utils.config import get_config +from stacky.utils.logging import cout, die, info +from stacky.utils.shell import run, run_always_return +from stacky.utils.types import BranchName, CmdArgs, Commit, FROZEN_STACK_BOTTOMS, STACK_BOTTOMS +from stacky.utils.ui import confirm + + +def cmd_update(stack: StackBranchSet, args): + """Update repo from remote.""" + remote = "origin" + start_muxed_ssh(remote) + info("Fetching from {}", remote) + run(CmdArgs(["git", "fetch", remote])) + + current_branch = get_current_branch_name() + for b in stack.bottoms: + run( + CmdArgs([ + "git", "update-ref", + "refs/heads/{}".format(b.name), + "refs/remotes/{}/{}".format(remote, b.remote_branch), + ]) + ) + if b.name == current_branch: + run(CmdArgs(["git", "reset", "--hard", "HEAD"])) + + info("Checking if any PRs have been merged and can be deleted") + forest = get_bottom_level_branches_as_forest(stack) + load_pr_info_for_forest(forest) + + deletes = get_branches_to_delete(forest) + if deletes and not args.force: + confirm() + + delete_branches(stack, deletes) + stop_muxed_ssh(remote) + + info("Cleaning up refs for non-existent branches") + cleanup_unused_refs(stack) + + +def cmd_import(stack: StackBranchSet, args): + """Import Graphite stack.""" + branch = args.name + branches = [] + bottoms = set(b.name for b in stack.bottoms) + while branch not in bottoms: + pr_info = get_pr_info(branch, full=True) + open_pr = pr_info.open + info("Getting PR information for {}", branch) + if open_pr is None: + die("Branch {} has no open PR", branch) + assert open_pr is not None + if open_pr["headRefName"] != branch: + die( + "Branch {} is misconfigured: PR #{} head is {}", + branch, open_pr["number"], open_pr["headRefName"], + ) + if not open_pr["commits"]: + die("PR #{} has no commits", open_pr["number"]) + first_commit = open_pr["commits"][0]["oid"] + parent_commit = Commit(run_always_return(CmdArgs(["git", "rev-parse", "{}^".format(first_commit)]))) + next_branch = open_pr["baseRefName"] + info( + "Branch {}: PR #{}, parent is {} at commit {}", + branch, open_pr["number"], next_branch, parent_commit, + ) + branches.append((branch, parent_commit)) + branch = next_branch + + if not branches: + return + + base_branch = branch + branches.reverse() + + for b, parent_commit in branches: + cout("- Will set parent of {} to {} at commit {}\n", b, branch, parent_commit) + branch = b + + if not args.force: + confirm() + + branch = base_branch + for b, parent_commit in branches: + set_parent(b, branch, set_origin=True) + set_parent_commit(b, parent_commit) + branch = b + + +def cmd_adopt(stack: StackBranch, args): + """Adopt a branch onto current stack bottom.""" + branch = args.name + current_branch = get_current_branch_name() + + if branch == current_branch: + die("A branch cannot adopt itself") + + if current_branch not in STACK_BOTTOMS: + main_branch = get_real_stack_bottom() + if get_config().change_to_main and main_branch is not None: + run(CmdArgs(["git", "checkout", main_branch])) + set_current_branch(main_branch) + current_branch = main_branch + else: + die( + "The current branch {} must be a valid stack bottom: {}", + 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)) + 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) + if get_config().change_to_adopted: + run(CmdArgs(["git", "checkout", branch])) diff --git a/src/stacky/commands/upstack.py b/src/stacky/commands/upstack.py new file mode 100644 index 0000000..73f1e53 --- /dev/null +++ b/src/stacky/commands/upstack.py @@ -0,0 +1,76 @@ +"""Upstack commands - info, push, sync, onto, as.""" + +from stacky.git.branch import get_current_branch_name +from stacky.git.refs import set_parent +from stacky.stack.models import StackBranchSet +from stacky.stack.operations import do_push, do_sync +from stacky.stack.tree import ( + forest_depth_first, get_current_upstack_as_forest, + load_pr_info_for_forest, print_forest +) +from stacky.utils.logging import die, info +from stacky.utils.shell import run +from stacky.utils.types import CmdArgs + + +def cmd_upstack_info(stack: StackBranchSet, args): + """Show info for current upstack.""" + forest = get_current_upstack_as_forest(stack) + if args.pr: + load_pr_info_for_forest(forest) + print_forest(forest) + + +def cmd_upstack_push(stack: StackBranchSet, args): + """Push current upstack.""" + do_push( + get_current_upstack_as_forest(stack), + force=args.force, + pr=args.pr, + remote_name=args.remote_name, + ) + + +def cmd_upstack_sync(stack: StackBranchSet, args): + """Sync current upstack.""" + do_sync(get_current_upstack_as_forest(stack)) + + +def cmd_upstack_onto(stack: StackBranchSet, args): + """Move current upstack onto a different parent.""" + current_branch = get_current_branch_name() + b = stack.stack[current_branch] + if not b.parent: + 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): + if ub == target: + die("Target branch {} is upstack of {}", target.name, b.name) + b.parent = target + set_parent(b.name, target.name) + do_sync(upstack) + + +def cmd_upstack_as_base(stack: StackBranchSet): + """Set current branch as a new stack bottom.""" + current_branch = get_current_branch_name() + 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): + """Upstack branch as something (e.g., bottom).""" + if args.target == "bottom": + cmd_upstack_as_base(stack) + else: + die("Invalid target {}, acceptable targets are [base]", args.target) diff --git a/src/stacky/git/__init__.py b/src/stacky/git/__init__.py new file mode 100644 index 0000000..c237e48 --- /dev/null +++ b/src/stacky/git/__init__.py @@ -0,0 +1 @@ +# Git module - git operations for stacky diff --git a/src/stacky/git/branch.py b/src/stacky/git/branch.py new file mode 100644 index 0000000..f68aacf --- /dev/null +++ b/src/stacky/git/branch.py @@ -0,0 +1,104 @@ +"""Branch operations for stacky.""" + +from typing import List, Optional + +from stacky.utils.logging import info +from stacky.utils.shell import remove_prefix, run, run_always_return, run_multiline +from stacky.utils.types import BranchName, CmdArgs, PathName, STACK_BOTTOMS + +# Global current branch - set by init_git() +CURRENT_BRANCH: BranchName = BranchName("") + + +def get_current_branch_name() -> BranchName: + """Get the current branch name (from global state).""" + return CURRENT_BRANCH + + +def set_current_branch(branch: BranchName): + """Set the current branch (global state).""" + global CURRENT_BRANCH + CURRENT_BRANCH = branch + + +def get_current_branch() -> Optional[BranchName]: + """Get the current branch from git.""" + s = run(CmdArgs(["git", "symbolic-ref", "-q", "HEAD"])) + if s is not None: + return BranchName(remove_prefix(s, "refs/heads/")) + return None + + +def get_all_branches() -> List[BranchName]: + """Get all local branches.""" + branches = run_multiline(CmdArgs(["git", "for-each-ref", "--format", "%(refname:short)", "refs/heads"])) + assert branches is not None + 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]: + """Return the actual stack bottom for this current repo.""" + branches = get_all_branches() + candidates = set() + for b in branches: + if b in STACK_BOTTOMS: + candidates.add(b) + + if len(candidates) == 1: + return candidates.pop() + return None + + +def get_stack_parent_branch(branch: BranchName) -> Optional[BranchName]: + """Get the parent branch of a stack branch.""" + if branch in STACK_BOTTOMS: + return None + 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) + return None + + +def get_top_level_dir() -> PathName: + """Get the top-level directory of the git repository.""" + p = run_always_return(CmdArgs(["git", "rev-parse", "--show-toplevel"])) + return PathName(p) + + +def checkout(branch: BranchName): + """Checkout a branch.""" + info("Checking out branch {}", branch) + run(["git", "checkout", branch], out=True) + + +def create_branch(branch: BranchName): + """Create a new branch tracking current branch.""" + run(["git", "checkout", "-b", branch, "--track"], out=True) + + +def init_git(): + """Initialize git state for stacky.""" + from stacky.utils.logging import die + + push_default = run(["git", "config", "remote.pushDefault"], check=False) + if push_default is not None: + die("`git config remote.pushDefault` may not be set") + auth_status = run(["gh", "auth", "status"], check=False) + if auth_status is None: + die("`gh` authentication failed") + global CURRENT_BRANCH + current = get_current_branch() + if current is not None: + CURRENT_BRANCH = current diff --git a/src/stacky/git/refs.py b/src/stacky/git/refs.py new file mode 100644 index 0000000..e4d63ed --- /dev/null +++ b/src/stacky/git/refs.py @@ -0,0 +1,107 @@ +"""Git ref operations for stacky.""" + +from typing import List, Optional + +from stacky.utils.logging import die +from stacky.utils.shell import run, run_multiline +from stacky.utils.types import BranchName, CmdArgs, Commit + + +def get_stack_parent_commit(branch: BranchName) -> Optional[Commit]: + """Get the parent commit of a stack branch.""" + c = run( + CmdArgs(["git", "rev-parse", "refs/stack-parent/{}".format(branch)]), + check=False, + ) + if c is not None: + return Commit(c) + return None + + +def get_commit(branch: BranchName) -> Commit: + """Get the current commit of a branch.""" + c = run(CmdArgs(["git", "rev-parse", "refs/heads/{}".format(branch)]), check=False) + assert c is not None + return Commit(c) + + +def set_parent_commit(branch: BranchName, new_commit: Commit, prev_commit: Optional[str] = None): + """Set the parent commit ref for a branch.""" + cmd = [ + "git", + "update-ref", + "refs/stack-parent/{}".format(branch), + new_commit, + ] + if prev_commit is not None: + cmd.append(prev_commit) + run(CmdArgs(cmd)) + + +def set_parent(branch: BranchName, target: Optional[BranchName], *, set_origin: bool = False): + """Set the parent branch for a stack branch.""" + 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 if target is not None else branch), + ] + ) + ) + + if target is None: + run( + CmdArgs( + [ + "git", + "update-ref", + "-d", + "refs/stack-parent/{}".format(branch), + ] + ) + ) + + +def get_branch_name_from_short_ref(ref: str) -> BranchName: + """Extract branch name from a short ref like 'stack-parent/branch'.""" + parts = ref.split("/", 1) + if len(parts) != 2: + die("invalid ref: {}".format(ref)) + return BranchName(parts[1]) + + +def get_all_stack_bottoms() -> List[BranchName]: + """Get all custom stack bottom branches.""" + 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]: + """Get all branches that have stack-parent refs.""" + 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 get_commits_between(a: Commit, b: Commit) -> List[str]: + """Get list of commits between two refs.""" + 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 + return [x.strip() for x in lines.split("\n")][:-1] + + +def get_merge_base(b1: BranchName, b2: BranchName) -> Optional[str]: + """Get the merge base of two branches.""" + return run(CmdArgs(["git", "merge-base", str(b1), str(b2)])) diff --git a/src/stacky/git/remote.py b/src/stacky/git/remote.py new file mode 100644 index 0000000..4e53f5a --- /dev/null +++ b/src/stacky/git/remote.py @@ -0,0 +1,100 @@ +"""Remote and SSH operations for stacky.""" + +import os +import re +import subprocess +import time +from typing import Optional, Tuple + +from stacky.utils.config import get_config +from stacky.utils.logging import die, error, info +from stacky.utils.shell import run, run_always_return +from stacky.utils.types import BranchName, CmdArgs, Commit, MAX_SSH_MUX_LIFETIME, STACK_BOTTOMS + + +def get_remote_info(branch: BranchName) -> Tuple[str, BranchName, Optional[Commit]]: + """Get remote info for a branch: (remote, remote_branch, remote_branch_commit).""" + if branch not in STACK_BOTTOMS: + remote = run(CmdArgs(["git", "config", "branch.{}.remote".format(branch)]), check=False) + if remote != ".": + die("Misconfigured branch {}: remote {}", branch, remote) + + # TODO(tudor): Maybe add a way to change these. + remote = "origin" + remote_branch = branch + + remote_commit = run( + CmdArgs(["git", "rev-parse", "refs/remotes/{}/{}".format(remote, remote_branch)]), + check=False, + ) + + commit = None + if remote_commit is not None: + commit = Commit(remote_commit) + + return (remote, BranchName(remote_branch), commit) + + +def get_remote_type(remote: str = "origin") -> Optional[str]: + """Get the SSH host type for a remote.""" + out = run_always_return(CmdArgs(["git", "remote", "-v"])) + for l in out.split("\n"): + match = re.match(r"^{}\s+(?:ssh://)?([^/]*):(?!//).*\s+\(push\)$".format(remote), l) + if match: + sshish_host = match.group(1) + return sshish_host + + return None + + +def gen_ssh_mux_cmd() -> list[str]: + """Generate SSH multiplexing command arguments.""" + args = [ + "ssh", + "-o", + "ControlMaster=auto", + "-o", + f"ControlPersist={MAX_SSH_MUX_LIFETIME}", + "-o", + "ControlPath=~/.ssh/stacky-%C", + ] + return args + + +def start_muxed_ssh(remote: str = "origin"): + """Start a multiplexed SSH connection.""" + if not get_config().share_ssh_session: + return + hostish = get_remote_type(remote) + if hostish is not None: + info("Creating a muxed ssh connection") + cmd = gen_ssh_mux_cmd() + os.environ["GIT_SSH_COMMAND"] = " ".join(cmd) + cmd.append("-MNf") + cmd.append(hostish) + # We don't want to use the run() wrapper because + # we don't want to wait for the process to finish + + p = subprocess.Popen(cmd, stderr=subprocess.PIPE) + # Wait a little bit for the connection to establish + # before carrying on + while p.poll() is None: + time.sleep(1) + if p.returncode != 0: + if p.stderr is not None: + err = p.stderr.read() + else: + err = b"unknown" + die(f"Failed to start ssh muxed connection, error was: {err.decode('utf-8').strip()}") + + +def stop_muxed_ssh(remote: str = "origin"): + """Stop a multiplexed SSH connection.""" + if get_config().share_ssh_session: + hostish = get_remote_type(remote) + if hostish is not None: + cmd = gen_ssh_mux_cmd() + cmd.append("-O") + cmd.append("exit") + cmd.append(hostish) + subprocess.Popen(cmd, stderr=subprocess.DEVNULL) diff --git a/src/stacky/main.py b/src/stacky/main.py new file mode 100644 index 0000000..1536959 --- /dev/null +++ b/src/stacky/main.py @@ -0,0 +1,324 @@ +"""Main entry point for stacky.""" + +import json +import logging +import os +import sys +from argparse import ArgumentParser + +import argcomplete # type: ignore + +from stacky.git.branch import ( + branch_name_completer, get_current_branch_name, get_real_stack_bottom, + init_git, set_current_branch +) +from stacky.stack.models import StackBranchSet +from stacky.stack.operations import inner_do_sync, load_all_stacks, load_stack_for_given_branch +from stacky.stack.tree import get_current_stack_as_forest +from stacky.utils.config import get_config +from stacky.utils.logging import ( + ExitException, _LOGGING_FORMAT, error, set_color_mode +) +from stacky.utils.shell import run +from stacky.utils.types import BranchName, LOGLEVELS, STATE_FILE + +# Import all command handlers +from stacky.commands.navigation import cmd_info, cmd_log, cmd_branch_up, cmd_branch_down +from stacky.commands.branch import cmd_branch_new, cmd_branch_commit, cmd_branch_checkout +from stacky.commands.commit import cmd_commit, cmd_amend +from stacky.commands.stack import cmd_stack_info, cmd_stack_push, cmd_stack_sync, cmd_stack_checkout +from stacky.commands.upstack import ( + cmd_upstack_info, cmd_upstack_push, cmd_upstack_sync, cmd_upstack_onto, cmd_upstack_as +) +from stacky.commands.downstack import cmd_downstack_info, cmd_downstack_push, cmd_downstack_sync +from stacky.commands.update import cmd_update, cmd_import, cmd_adopt +from stacky.commands.land import cmd_land +from stacky.commands.inbox import cmd_inbox, cmd_prs +from stacky.commands.fold import ( + cmd_fold, inner_do_fold, finish_merge_fold_operation +) + + +def main(): + """Main entry point for stacky.""" + logging.basicConfig(format=_LOGGING_FORMAT, level=logging.INFO) + try: + parser = ArgumentParser(description="Handle git stacks") + parser.add_argument( + "--log-level", default="info", choices=LOGLEVELS.keys(), + help="Set the log level", + ) + parser.add_argument( + "--color", default="auto", choices=["always", "auto", "never"], + help="Colorize output and error", + ) + parser.add_argument( + "--remote-name", "-r", default="origin", + help="name of the git remote where branches will be pushed", + ) + + subparsers = parser.add_subparsers(required=True, dest="command") + + # continue + continue_parser = subparsers.add_parser("continue", help="Continue previously interrupted command") + continue_parser.set_defaults(func=None) + + # down / up + down_parser = subparsers.add_parser("down", help="Go down in the current stack (towards master/main)") + down_parser.set_defaults(func=cmd_branch_down) + up_parser = subparsers.add_parser("up", help="Go up in the current stack (away master/main)") + up_parser.set_defaults(func=cmd_branch_up) + + # info + info_parser = subparsers.add_parser("info", help="Stack info") + info_parser.add_argument("--pr", action="store_true", help="Get PR info (slow)") + info_parser.set_defaults(func=cmd_info) + + # log + log_parser = subparsers.add_parser("log", help="Show git log with conditional merge handling") + log_parser.set_defaults(func=cmd_log) + + # commit + commit_parser = subparsers.add_parser("commit", help="Commit") + commit_parser.add_argument("-m", help="Commit message", dest="message") + commit_parser.add_argument("--amend", action="store_true", help="Amend last commit") + commit_parser.add_argument("--allow-empty", action="store_true", help="Allow empty commit") + commit_parser.add_argument("--no-edit", action="store_true", help="Skip editor") + commit_parser.add_argument("-a", action="store_true", help="Add all files to commit", dest="add_all") + commit_parser.add_argument("--no-verify", action="store_true", help="Bypass pre-commit and commit-msg hooks") + commit_parser.set_defaults(func=cmd_commit) + + # amend + amend_parser = subparsers.add_parser("amend", help="Shortcut for amending last commit") + amend_parser.add_argument("--no-verify", action="store_true", help="Bypass pre-commit and commit-msg hooks") + amend_parser.set_defaults(func=cmd_amend) + + _setup_branch_subcommands(subparsers) + _setup_stack_subcommands(subparsers) + _setup_upstack_subcommands(subparsers) + _setup_downstack_subcommands(subparsers) + _setup_other_commands(subparsers) + + argcomplete.autocomplete(parser) + args = parser.parse_args() + logging.basicConfig(format=_LOGGING_FORMAT, level=LOGLEVELS[args.log_level], force=True) + set_color_mode(args.color) + + init_git() + stack = StackBranchSet() + load_all_stacks(stack) + + current_branch = get_current_branch_name() + if args.command == "continue": + _handle_continue(stack, current_branch) + else: + if current_branch not in stack.stack: + main_branch = get_real_stack_bottom() + if get_config().change_to_main and main_branch is not None: + run(["git", "checkout", main_branch]) + set_current_branch(main_branch) + else: + from stacky.utils.logging import die + die("Current branch {} is not in a stack", current_branch) + + get_current_stack_as_forest(stack) + args.func(stack, args) + + # Success, delete the state file + try: + os.remove(STATE_FILE) + except FileNotFoundError: + pass + except ExitException as e: + error("{}", e.args[0]) + sys.exit(1) + + +def _handle_continue(stack: StackBranchSet, current_branch: BranchName): + """Handle the 'continue' command for interrupted operations.""" + from stacky.utils.logging import die + + try: + with open(STATE_FILE) as f: + state = json.load(f) + except FileNotFoundError: + die("No previous command in progress") + + branch = state["branch"] + run(["git", "checkout", branch]) + set_current_branch(branch) + + if branch not in stack.stack: + die("Current branch {} is not in a stack", branch) + + if "sync" in state: + sync_names = state["sync"] + syncs = [stack.stack[n] for n in sync_names] + inner_do_sync(syncs, sync_names) + elif "fold" in state: + 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: + 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") + + +def _setup_branch_subcommands(subparsers): + """Setup branch subcommands.""" + branch_parser = subparsers.add_parser("branch", aliases=["b"], help="Operations on branches") + branch_subparsers = branch_parser.add_subparsers(required=True, dest="branch_command") + + branch_up_parser = branch_subparsers.add_parser("up", aliases=["u"], help="Move upstack") + branch_up_parser.set_defaults(func=cmd_branch_up) + + branch_down_parser = branch_subparsers.add_parser("down", aliases=["d"], help="Move downstack") + branch_down_parser.set_defaults(func=cmd_branch_down) + + branch_new_parser = branch_subparsers.add_parser("new", aliases=["create"], help="Create a new branch") + branch_new_parser.add_argument("name", help="Branch name") + branch_new_parser.set_defaults(func=cmd_branch_new) + + branch_commit_parser = branch_subparsers.add_parser("commit", help="Create a new branch and commit all changes") + branch_commit_parser.add_argument("name", help="Branch name") + branch_commit_parser.add_argument("-m", help="Commit message", dest="message") + branch_commit_parser.add_argument("-a", action="store_true", help="Add all files to commit", dest="add_all") + branch_commit_parser.add_argument("--no-verify", action="store_true", help="Bypass pre-commit and commit-msg hooks") + branch_commit_parser.set_defaults(func=cmd_branch_commit) + + branch_checkout_parser = branch_subparsers.add_parser("checkout", aliases=["co"], help="Checkout a branch") + branch_checkout_parser.add_argument("name", help="Branch name", nargs="?").completer = branch_name_completer + branch_checkout_parser.set_defaults(func=cmd_branch_checkout) + + +def _setup_stack_subcommands(subparsers): + """Setup stack subcommands.""" + stack_parser = subparsers.add_parser("stack", aliases=["s"], help="Operations on the full current stack") + stack_subparsers = stack_parser.add_subparsers(required=True, dest="stack_command") + + stack_info_parser = stack_subparsers.add_parser("info", aliases=["i"], help="Info for current stack") + stack_info_parser.add_argument("--pr", action="store_true", help="Get PR info (slow)") + stack_info_parser.set_defaults(func=cmd_stack_info) + + stack_push_parser = stack_subparsers.add_parser("push", help="Push") + stack_push_parser.add_argument("--force", "-f", action="store_true", help="Bypass confirmation") + stack_push_parser.add_argument("--no-pr", dest="pr", action="store_false", help="Skip Create PRs") + stack_push_parser.set_defaults(func=cmd_stack_push) + + stack_sync_parser = stack_subparsers.add_parser("sync", help="Sync") + stack_sync_parser.set_defaults(func=cmd_stack_sync) + + stack_checkout_parser = stack_subparsers.add_parser("checkout", aliases=["co"], help="Checkout a branch in this stack") + stack_checkout_parser.set_defaults(func=cmd_stack_checkout) + + +def _setup_upstack_subcommands(subparsers): + """Setup upstack subcommands.""" + upstack_parser = subparsers.add_parser("upstack", aliases=["us"], help="Operations on the current upstack") + upstack_subparsers = upstack_parser.add_subparsers(required=True, dest="upstack_command") + + upstack_info_parser = upstack_subparsers.add_parser("info", aliases=["i"], help="Info for current upstack") + upstack_info_parser.add_argument("--pr", action="store_true", help="Get PR info (slow)") + upstack_info_parser.set_defaults(func=cmd_upstack_info) + + upstack_push_parser = upstack_subparsers.add_parser("push", help="Push") + upstack_push_parser.add_argument("--force", "-f", action="store_true", help="Bypass confirmation") + upstack_push_parser.add_argument("--no-pr", dest="pr", action="store_false", help="Skip Create PRs") + upstack_push_parser.set_defaults(func=cmd_upstack_push) + + upstack_sync_parser = upstack_subparsers.add_parser("sync", help="Sync") + upstack_sync_parser.set_defaults(func=cmd_upstack_sync) + + upstack_onto_parser = upstack_subparsers.add_parser("onto", aliases=["restack"], help="Restack") + 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").completer = branch_name_completer + upstack_as_parser.set_defaults(func=cmd_upstack_as) + + +def _setup_downstack_subcommands(subparsers): + """Setup downstack subcommands.""" + downstack_parser = subparsers.add_parser("downstack", aliases=["ds"], help="Operations on the current downstack") + downstack_subparsers = downstack_parser.add_subparsers(required=True, dest="downstack_command") + + downstack_info_parser = downstack_subparsers.add_parser("info", aliases=["i"], help="Info for current downstack") + downstack_info_parser.add_argument("--pr", action="store_true", help="Get PR info (slow)") + downstack_info_parser.set_defaults(func=cmd_downstack_info) + + downstack_push_parser = downstack_subparsers.add_parser("push", help="Push") + downstack_push_parser.add_argument("--force", "-f", action="store_true", help="Bypass confirmation") + downstack_push_parser.add_argument("--no-pr", dest="pr", action="store_false", help="Skip Create PRs") + downstack_push_parser.set_defaults(func=cmd_downstack_push) + + downstack_sync_parser = downstack_subparsers.add_parser("sync", help="Sync") + downstack_sync_parser.set_defaults(func=cmd_downstack_sync) + + +def _setup_other_commands(subparsers): + """Setup other commands (update, import, adopt, land, shortcuts, etc.).""" + # update + 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) + + # 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").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").completer = branch_name_completer + adopt_parser.set_defaults(func=cmd_adopt) + + # land + land_parser = subparsers.add_parser("land", help="Land bottom-most PR on current stack") + land_parser.add_argument("--force", "-f", action="store_true", help="Bypass confirmation") + land_parser.add_argument("--auto", "-a", action="store_true", help="Automatically merge after all checks pass") + land_parser.set_defaults(func=cmd_land) + + # shortcuts + push_parser = subparsers.add_parser("push", help="Alias for downstack push") + push_parser.add_argument("--force", "-f", action="store_true", help="Bypass confirmation") + push_parser.add_argument("--no-pr", dest="pr", action="store_false", help="Skip Create PRs") + push_parser.set_defaults(func=cmd_downstack_push) + + sync_parser = subparsers.add_parser("sync", help="Alias for stack sync") + 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="?").completer = branch_name_completer + checkout_parser.set_defaults(func=cmd_branch_checkout) + + sco_parser = subparsers.add_parser("sco", help="Checkout a branch in this stack") + sco_parser.set_defaults(func=cmd_stack_checkout) + + # inbox + inbox_parser = subparsers.add_parser("inbox", help="List all active GitHub pull requests for the current user") + inbox_parser.add_argument("--compact", "-c", action="store_true", help="Show compact view") + inbox_parser.set_defaults(func=cmd_inbox) + + # prs + prs_parser = subparsers.add_parser("prs", help="Interactive PR management - select and edit PR descriptions") + prs_parser.set_defaults(func=cmd_prs) + + # fold + fold_parser = subparsers.add_parser("fold", help="Fold current branch into parent branch and delete current branch") + fold_parser.add_argument("--allow-empty", action="store_true", help="Allow empty commits during cherry-pick") + fold_parser.set_defaults(func=cmd_fold) diff --git a/src/stacky/pr/__init__.py b/src/stacky/pr/__init__.py new file mode 100644 index 0000000..c390929 --- /dev/null +++ b/src/stacky/pr/__init__.py @@ -0,0 +1 @@ +# PR module - GitHub PR operations diff --git a/src/stacky/pr/github.py b/src/stacky/pr/github.py new file mode 100644 index 0000000..d7ea68e --- /dev/null +++ b/src/stacky/pr/github.py @@ -0,0 +1,255 @@ +"""GitHub PR operations for stacky.""" + +import json +import logging +import os +import re +import subprocess +import tempfile +from typing import Dict, List, Optional, TYPE_CHECKING + +from stacky.stack.models import PRInfo, PRInfos +from stacky.stack.tree import get_pr_status_emoji +from stacky.utils.config import get_config +from stacky.utils.logging import COLOR_STDOUT, cout, fmt +from stacky.utils.shell import run, run_always_return, run_multiline +from stacky.utils.types import BranchesTreeForest, BranchName, CmdArgs, STACK_BOTTOMS + +if TYPE_CHECKING: + from stacky.stack.models import StackBranch + + +def get_pr_info(branch: BranchName, *, full: bool = False) -> PRInfos: + """Get PR information for a branch.""" + from stacky.utils.logging import die + + fields = [ + "id", "number", "state", "mergeable", "url", "title", + "baseRefName", "headRefName", "reviewDecision", "reviewRequests", "isDraft", + ] + if full: + fields += ["commits"] + data = json.loads( + run_always_return( + CmdArgs([ + "gh", "pr", "list", "--json", ",".join(fields), + "--state", "all", "--head", branch, + ]) + ) + ) + raw_infos: List[PRInfo] = data + + infos: Dict[str, PRInfo] = {info["id"]: info for info in raw_infos} + open_prs: List[PRInfo] = [info for info in infos.values() if info["state"] == "OPEN"] + if len(open_prs) > 1: + die( + "Branch {} has more than one open PR: {}", + branch, ", ".join([str(pr) for pr in open_prs]), + ) + return PRInfos(infos, open_prs[0] if open_prs else None) + + +def find_reviewers(b: "StackBranch") -> Optional[List[str]]: + """Find reviewers from commit message.""" + out = run_multiline( + CmdArgs(["git", "log", "--pretty=format:%b", "-1", f"{b.name}"]), + ) + assert out is not None + for l in out.split("\n"): + reviewer_match = re.match(r"^reviewers?\s*:\s*(.*)", l, re.I) + if reviewer_match: + reviewers = reviewer_match.group(1).split(",") + logging.debug(f"Found the following reviewers: {', '.join(reviewers)}") + return reviewers + return None + + +def find_issue_marker(name: str) -> Optional[str]: + """Find issue marker (e.g. SRE-123) in branch name.""" + match = re.search(r"(?:^|[_-])([A-Z]{3,}[_-]?\d{2,})($|[_-].*)", name) + if match: + res = match.group(1) + if "_" in res: + return res.replace("_", "-") + if "-" not in res: + newmatch = re.match(r"(...)(\d+)", res) + assert newmatch is not None + return f"{newmatch.group(1)}-{newmatch.group(2)}" + return res + return None + + +def create_gh_pr(b: "StackBranch", prefix: str): + """Create a GitHub PR for a branch.""" + from stacky.utils.ui import prompt + + cout("Creating PR for {}\n", b.name, fg="green") + parent_prefix = "" + if b.parent.name not in STACK_BOTTOMS: + prefix = "" + cmd = [ + "gh", "pr", "create", + "--head", f"{prefix}{b.name}", + "--base", f"{parent_prefix}{b.parent.name}", + ] + reviewers = find_reviewers(b) + issue_id = find_issue_marker(b.name) + if issue_id: + out = run_multiline( + CmdArgs(["git", "log", "--pretty=oneline", f"{b.parent.name}..{b.name}"]), + ) + title = f"[{issue_id}] " + if out is not None and len(out.split("\n")) == 2: + out = run( + CmdArgs(["git", "log", "--pretty=format:%s", "-1", f"{b.name}"]), + out=False, + ) + if out is None: + out = "" + if b.name not in out: + title += out + else: + title = out + + title = prompt( + (fmt("? ", color=COLOR_STDOUT, fg="green") + + fmt("Title ", color=COLOR_STDOUT, style="bold", fg="white")), + title, + ) + cmd.extend(["--title", title.strip()]) + if reviewers: + logging.debug(f"Adding {len(reviewers)} reviewer(s) to the review") + for r in reviewers: + r = r.strip() + r = r.replace("#", "rockset/") + if len(r) > 0: + cmd.extend(["--reviewer", r]) + + run(CmdArgs(cmd), out=True) + + +def generate_stack_string(forest: BranchesTreeForest, current_branch: "StackBranch") -> str: + """Generate a string representation of the PR stack.""" + from stacky.stack.tree import BranchesTree + + stack_lines = [] + + def add_branch_to_stack(b: "StackBranch", depth: int): + if b.name in STACK_BOTTOMS: + return + indent = " " * depth + pr_info = "" + if b.open_pr_info: + pr_number = b.open_pr_info['number'] + status_emoji = get_pr_status_emoji(b.open_pr_info) + pr_info = f" (#{pr_number}{status_emoji})" + current_indicator = " ← (CURRENT PR)" if b.name == current_branch.name else "" + stack_lines.append(f"{indent}- {b.name}{pr_info}{current_indicator}") + + def traverse_tree(tree: BranchesTree, depth: int): + for _, (branch, children) in tree.items(): + add_branch_to_stack(branch, depth) + traverse_tree(children, depth + 1) + + for tree in forest: + traverse_tree(tree, 0) + + if not stack_lines: + return "" + + return "\n".join([ + "", + "**Stack:**", + *stack_lines, + "" + ]) + + +def extract_stack_comment(body: str) -> str: + """Extract existing stack comment from PR body.""" + if not body: + return "" + 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", complete_forest: BranchesTreeForest): + """Add or update stack comment in PR body.""" + if not branch.open_pr_info: + return + + pr_number = branch.open_pr_info["number"] + pr_data = json.loads( + run_always_return(CmdArgs(["gh", "pr", "view", str(pr_number), "--json", "body"])) + ) + + current_body = pr_data.get("body", "") + stack_string = generate_stack_string(complete_forest, branch) + + if not stack_string: + return + + existing_stack = extract_stack_comment(current_body) + + if not existing_stack: + 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: + if existing_stack != stack_string: + 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 edit_pr_description(pr): + """Edit a PR's description using the user's default editor.""" + 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") + + 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: + editor = os.environ.get('EDITOR', 'vim') + result = subprocess.run([editor, temp_file_path]) + if result.returncode != 0: + cout("Editor exited with error, not updating PR description.\n", fg="red") + return + + with open(temp_file_path, 'r') as temp_file: + new_body = temp_file.read().strip() + + original_content = (current_body or "").strip() + new_content = new_body.strip() + + if new_content == original_content: + cout("No changes made to PR description.\n", fg="yellow") + return + + 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") + pr["body"] = new_body + + except Exception as e: + cout("Error editing PR description: {}\n", str(e), fg="red") + finally: + try: + os.unlink(temp_file_path) + except OSError: + pass diff --git a/src/stacky/stack/__init__.py b/src/stacky/stack/__init__.py new file mode 100644 index 0000000..2665fb6 --- /dev/null +++ b/src/stacky/stack/__init__.py @@ -0,0 +1 @@ +# Stack module - stack data structures and operations diff --git a/src/stacky/stack/models.py b/src/stacky/stack/models.py new file mode 100644 index 0000000..760b69a --- /dev/null +++ b/src/stacky/stack/models.py @@ -0,0 +1,140 @@ +"""Stack data models for stacky.""" + +import dataclasses +from typing import Dict, List, Optional, TypedDict + +from stacky.git.refs import get_commit +from stacky.git.remote import get_remote_info +from stacky.utils.logging import die +from stacky.utils.types import BranchName, Commit + + +class PRInfo(TypedDict): + """Type definition for PR information from GitHub.""" + id: str + number: int + state: str + mergeable: str + url: str + title: str + baseRefName: str + headRefName: str + commits: List[Dict[str, str]] + + +@dataclasses.dataclass +class PRInfos: + """Container for all PRs and the open PR for a branch.""" + all: Dict[str, PRInfo] + open: Optional[PRInfo] + + +@dataclasses.dataclass +class BranchNCommit: + """Branch name with its parent commit.""" + branch: BranchName + parent_commit: Optional[str] + + +class StackBranch: + """Represents a branch in a stack.""" + + def __init__( + self, + name: BranchName, + parent: "StackBranch", + parent_commit: Commit, + ): + self.name = name + self.parent = parent + self.parent_commit = parent_commit + self.children: set["StackBranch"] = set() + self.commit = get_commit(name) + self.remote, self.remote_branch, self.remote_commit = get_remote_info(name) + self.pr_info: Dict[str, PRInfo] = {} + self.open_pr_info: Optional[PRInfo] = None + self._pr_info_loaded = False + + def is_synced_with_parent(self): + """Check if branch is synced with its parent.""" + return self.parent is None or self.parent_commit == self.parent.commit + + def is_synced_with_remote(self): + """Check if branch is synced with remote.""" + return self.commit == self.remote_commit + + def __repr__(self): + return f"StackBranch: {self.name} {len(self.children)} {self.commit}" + + def load_pr_info(self): + """Load PR info from GitHub (lazy loading).""" + if not self._pr_info_loaded: + self._pr_info_loaded = True + from stacky.pr.github import get_pr_info + pr_infos = get_pr_info(self.name) + self.pr_info, self.open_pr_info = ( + pr_infos.all, + pr_infos.open, + ) + + +class StackBranchSet: + """Collection of stack branches.""" + + def __init__(self: "StackBranchSet"): + self.stack: Dict[BranchName, StackBranch] = {} + self.tops: set[StackBranch] = set() + self.bottoms: set[StackBranch] = set() + + def add(self, name: BranchName, **kwargs) -> StackBranch: + """Add a branch to the stack.""" + if name in self.stack: + s = self.stack[name] + assert s.name == name + for k, v in kwargs.items(): + if getattr(s, k) != v: + die( + "Mismatched stack: {}: {}={}, expected {}", + name, + k, + getattr(s, k), + v, + ) + else: + s = StackBranch(name, **kwargs) + self.stack[name] = s + if s.parent is None: + self.bottoms.add(s) + self.tops.add(s) + return s + + def addStackBranch(self, s: StackBranch): + """Add an existing StackBranch object to the set.""" + 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]: + """Remove a branch from the stack.""" + 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: + return f"StackBranchSet: {self.stack}" + + def add_child(self, s: StackBranch, child: StackBranch): + """Add a child branch to a parent.""" + s.children.add(child) + self.tops.discard(s) diff --git a/src/stacky/stack/operations.py b/src/stacky/stack/operations.py new file mode 100644 index 0000000..ed24bc2 --- /dev/null +++ b/src/stacky/stack/operations.py @@ -0,0 +1,349 @@ +"""Stack operations for stacky - loading, syncing, pushing.""" + +import json +import os +from typing import List, Optional, Tuple, TYPE_CHECKING + +from stacky.git.branch import ( + get_all_branches, get_current_branch_name, get_stack_parent_branch, set_current_branch +) +from stacky.git.refs import ( + get_all_stack_bottoms, get_commit, get_commits_between, + get_stack_parent_commit, set_parent_commit +) +from stacky.git.remote import start_muxed_ssh, stop_muxed_ssh +from stacky.stack.models import BranchNCommit, StackBranch, StackBranchSet +from stacky.stack.tree import ( + forest_depth_first, get_complete_stack_forest_for_branch, + load_pr_info_for_forest, print_forest +) +from stacky.utils.config import get_config +from stacky.utils.logging import cout, die, info, warning +from stacky.utils.shell import run, run_always_return +from stacky.utils.types import ( + BranchesTreeForest, BranchName, CmdArgs, Commit, + STACK_BOTTOMS, STATE_FILE, TMP_STATE_FILE +) + +if TYPE_CHECKING: + pass + + +def load_all_stack_bottoms(): + """Load all custom stack bottoms into STACK_BOTTOMS.""" + STACK_BOTTOMS.update(get_all_stack_bottoms()) + + +def load_stack_for_given_branch( + stack: StackBranchSet, branch: BranchName, *, check: bool = True +) -> Tuple[Optional[StackBranch], List[BranchName]]: + """Load stack for a branch, returns (top_branch, list_of_branches).""" + branches: List[BranchNCommit] = [] + while branch not in STACK_BOTTOMS: + parent = get_stack_parent_branch(branch) + parent_commit = get_stack_parent_commit(branch) + branches.append(BranchNCommit(branch, parent_commit)) + if not parent or not parent_commit: + if check: + die("Branch is not in a stack: {}", branch) + return None, [b.branch for b in branches] + branch = parent + + branches.append(BranchNCommit(branch, None)) + top = None + for b in reversed(branches): + n = stack.add( + b.branch, + parent=top, + parent_commit=b.parent_commit, + ) + if top: + stack.add_child(top, n) + top = n + + return top, [b.branch for b in branches] + + +def load_all_stacks(stack: StackBranchSet) -> Optional[StackBranch]: + """Load all stacks, return top of current branch's stack.""" + load_all_stack_bottoms() + all_branches = set(get_all_branches()) + current_branch = get_current_branch_name() + current_branch_top = None + while all_branches: + b = all_branches.pop() + top, branches = load_stack_for_given_branch(stack, b, check=False) + all_branches -= set(branches) + if top is None: + if len(branches) > 1: + warning("Broken stack: {}", " -> ".join(branches)) + continue + if b == current_branch: + current_branch_top = top + return current_branch_top + + +def inner_do_sync(syncs: List[StackBranch], sync_names: List[BranchName]): + """Execute sync operations on branches.""" + print() + current_branch = get_current_branch_name() + 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) + os.replace(TMP_STATE_FILE, STATE_FILE) + + b = syncs.pop() + sync_names.pop() + if b.is_synced_with_parent(): + cout("{} is already synced on top of {}\n", b.name, b.parent.name) + continue + if b.parent.commit in get_commits_between(b.parent_commit, b.commit): + cout( + "Recording complete {} of {} on top of {}\n", + sync_type, b.name, b.parent.name, fg="green", + ) + else: + 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 {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) + b.parent_commit = b.parent.commit + run(CmdArgs(["git", "checkout", str(current_branch)])) + + +def do_sync(forest: BranchesTreeForest): + """Sync a forest of branches.""" + print_forest(forest) + + syncs: List[StackBranch] = [] + sync_names: List[BranchName] = [] + syncs_set: set[StackBranch] = set() + for b in forest_depth_first(forest): + if not b.parent: + cout("✓ Not syncing base branch {}\n", b.name, fg="green") + continue + if b.is_synced_with_parent() and b.parent not in syncs_set: + cout( + "✓ Not syncing branch {}, already synced with parent {}\n", + b.name, b.parent.name, fg="green", + ) + continue + syncs.append(b) + syncs_set.add(b) + sync_names.append(b.name) + cout("- Will sync branch {} on top of {}\n", b.name, b.parent.name) + + if not syncs: + return + + syncs.reverse() + sync_names.reverse() + inner_do_sync(syncs, sync_names) + + +def do_push( + forest: BranchesTreeForest, + *, + force: bool = False, + pr: bool = False, + remote_name: str = "origin", +): + """Push branches in a forest.""" + from stacky.pr.github import add_or_update_stack_comment, create_gh_pr + from stacky.utils.ui import confirm + + if pr: + load_pr_info_for_forest(forest) + print_forest(forest) + for b in forest_depth_first(forest): + if not b.is_synced_with_parent(): + die( + "Branch {} is not synced with parent {}, sync first", + b.name, b.parent.name, + ) + + PR_NONE = 0 + PR_FIX_BASE = 1 + PR_CREATE = 2 + actions = [] + for b in forest_depth_first(forest): + if not b.parent: + cout("✓ Not pushing base branch {}\n", b.name, fg="green") + continue + + push = False + if b.is_synced_with_remote(): + cout( + "✓ Not pushing branch {}, synced with remote {}/{}\n", + b.name, b.remote, b.remote_branch, fg="green", + ) + else: + cout("- Will push branch {} to {}/{}\n", b.name, b.remote, b.remote_branch) + push = True + + pr_action = PR_NONE + if pr: + if b.open_pr_info: + expected_base = b.parent.name + if b.open_pr_info["baseRefName"] != expected_base: + cout( + "- Branch {} already has open PR #{}; will change PR base from {} to {}\n", + b.name, b.open_pr_info["number"], + b.open_pr_info["baseRefName"], expected_base, + ) + pr_action = PR_FIX_BASE + else: + cout( + "✓ Branch {} already has open PR #{}\n", + b.name, b.open_pr_info["number"], fg="green", + ) + else: + cout("- Will create PR for branch {}\n", b.name) + pr_action = PR_CREATE + + if not push and pr_action == PR_NONE: + continue + actions.append((b, push, pr_action)) + + if actions and not force: + confirm() + + # Figure out prefix for branch (e.g. user:branch for forks) + val = run(CmdArgs(["git", "config", f"remote.{remote_name}.gh-resolved"]), check=False) + if val is not None and "/" in val: + val = run_always_return(CmdArgs(["git", "config", f"remote.{remote_name}.url"])) + prefix = f'{val.split(":")[1].split("/")[0]}:' + else: + prefix = "" + + muxed = False + for b, push, pr_action in actions: + if push: + if not muxed: + start_muxed_ssh(remote_name) + muxed = True + 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(cmd_args), out=True) + if pr_action == PR_FIX_BASE: + cout("Fixing PR base for {}\n", b.name, fg="green") + assert b.open_pr_info is not None + run( + CmdArgs([ + "gh", "pr", "edit", str(b.open_pr_info["number"]), + "--base", b.parent.name, + ]), + out=True, + ) + elif pr_action == PR_CREATE: + create_gh_pr(b, prefix) + + # Handle stack comments for PRs + if pr and get_config().enable_stack_comment: + load_pr_info_for_forest(forest) + 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: + 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: + complete_forest = get_complete_stack_forest_for_branch(b) + load_pr_info_for_forest(complete_forest) + complete_forests_by_root[root_name] = complete_forest + + 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) + + +def get_branches_to_delete(forest: BranchesTreeForest) -> List[StackBranch]: + """Get branches that can be deleted (PRs merged).""" + deletes = [] + for b in forest_depth_first(forest): + if not b.parent or b.open_pr_info: + continue + for pr_info in b.pr_info.values(): + if pr_info["state"] != "MERGED": + continue + cout( + "- Will delete branch {}, PR #{} merged into {}\n", + b.name, pr_info["number"], b.parent.name, + ) + deletes.append(b) + for c in b.children: + cout("- Will reparent branch {} onto {}\n", c.name, b.parent.name) + break + return deletes + + +def delete_branches(stack: StackBranchSet, deletes: List[StackBranch]): + """Delete merged branches and reparent their children.""" + from stacky.git.refs import set_parent + + current_branch = get_current_branch_name() + for b in deletes: + for c in b.children: + info("Reparenting {} onto {}", c.name, b.parent.name) + c.parent = b.parent + set_parent(c.name, b.parent.name) + info("Deleting {}", b.name) + if b.name == current_branch: + new_branch = next(iter(stack.bottoms)) + info("About to delete current branch, switching to {}", new_branch.name) + run(CmdArgs(["git", "checkout", new_branch.name])) + set_current_branch(new_branch.name) + run(CmdArgs(["git", "branch", "-D", b.name])) + + +def cleanup_unused_refs(stack: StackBranchSet): + """Clean up refs for non-existent branches.""" + from stacky.git.refs import get_all_stack_parent_refs + + info("Cleaning up unused refs") + existing_branches = set(get_all_branches()) + + stack_bottoms = get_all_stack_bottoms() + for bottom in stack_bottoms: + if bottom not in stack.stack or bottom not in existing_branches: + ref = "refs/stacky-bottom-branch/{}".format(bottom) + info("Deleting ref {} (branch {} no longer exists)".format(ref, bottom)) + 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 or br not in existing_branches: + ref = "refs/stack-parent/{}".format(br) + 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])) diff --git a/src/stacky/stack/tree.py b/src/stacky/stack/tree.py new file mode 100644 index 0000000..9417f39 --- /dev/null +++ b/src/stacky/stack/tree.py @@ -0,0 +1,193 @@ +"""Tree formatting and traversal for stacky stacks.""" + +from typing import Generator, List, TYPE_CHECKING + +from stacky.git.branch import get_current_branch_name +from stacky.utils.config import get_config +from stacky.utils.logging import COLOR_STDOUT, fmt +from stacky.utils.types import BranchesTree, BranchesTreeForest, BranchName, TreeNode + +if TYPE_CHECKING: + from stacky.stack.models import StackBranch, StackBranchSet + + +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 make_tree_node(b: "StackBranch") -> TreeNode: + """Create a tree node for a branch.""" + return (b.name, (b, make_subtree(b))) + + +def make_subtree(b: "StackBranch") -> BranchesTree: + """Create a subtree for a branch's children.""" + return BranchesTree(dict(make_tree_node(c) for c in sorted(b.children, key=lambda x: x.name))) + + +def make_tree(b: "StackBranch") -> BranchesTree: + """Create a tree rooted at a branch.""" + return BranchesTree(dict([make_tree_node(b)])) + + +def format_name(b: "StackBranch", *, colorize: bool) -> str: + """Format a branch name with status indicators.""" + current_branch = get_current_branch_name() + prefix = "" + severity = 0 + # TODO: Align things so that we have the same prefix length? + if not b.is_synced_with_parent(): + prefix += fmt("!", color=colorize, fg="yellow") + severity = max(severity, 2) + if not b.is_synced_with_remote(): + prefix += fmt("~", color=colorize, fg="yellow") + if b.name == current_branch: + prefix += fmt("*", color=colorize, fg="cyan") + else: + severity = max(severity, 1) + if prefix: + prefix += " " + fg = ["cyan", "green", "yellow", "red"][severity] + suffix = "" + if b.open_pr_info: + suffix += " " + # 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 + + +def format_tree(tree: BranchesTree, *, colorize: bool = False): + """Format a tree for display.""" + return { + format_name(branch, colorize=colorize): format_tree(children, colorize=colorize) + for branch, children in tree.values() + } + + +def print_tree(tree: BranchesTree): + """Print a tree (upside down to match upstack/downstack nomenclature).""" + from stacky.utils.ui import ASCII_TREE + s = ASCII_TREE(format_tree(tree, colorize=COLOR_STDOUT)) + lines = s.split("\n") + print("\n".join(reversed(lines))) + + +def print_forest(trees: List[BranchesTree]): + """Print multiple trees.""" + for i, t in enumerate(trees): + if i != 0: + print() + print_tree(t) + + +def forest_depth_first(forest: BranchesTreeForest) -> Generator["StackBranch", None, None]: + """Iterate over a forest in depth-first order.""" + for tree in forest: + for b in depth_first(tree): + yield b + + +def depth_first(tree: BranchesTree) -> Generator["StackBranch", None, None]: + """Iterate over a tree in depth-first order.""" + for _, (branch, children) in tree.items(): + yield branch + for b in depth_first(children): + yield b + + +def get_all_stacks_as_forest(stack: "StackBranchSet") -> BranchesTreeForest: + """Get all stacks as a forest.""" + return BranchesTreeForest([make_tree(b) for b in stack.bottoms]) + + +def get_current_stack_as_forest(stack: "StackBranchSet") -> BranchesTreeForest: + """Get the current stack as a forest.""" + current_branch = get_current_branch_name() + b = stack.stack[current_branch] + d: BranchesTree = make_tree(b) + b = b.parent + while b: + d = BranchesTree({b.name: (b, d)}) + b = b.parent + return [d] + + +def get_current_upstack_as_forest(stack: "StackBranchSet") -> BranchesTreeForest: + """Get the current upstack (current branch and above) as a forest.""" + current_branch = get_current_branch_name() + b = stack.stack[current_branch] + return BranchesTreeForest([make_tree(b)]) + + +def get_current_downstack_as_forest(stack: "StackBranchSet") -> BranchesTreeForest: + """Get the current downstack (current branch and below) as a forest.""" + current_branch = get_current_branch_name() + b = stack.stack[current_branch] + d: BranchesTree = BranchesTree({}) + while b: + d = BranchesTree({b.name: (b, d)}) + b = b.parent + return BranchesTreeForest([d]) + + +def get_bottom_level_branches_as_forest(stack: "StackBranchSet") -> BranchesTreeForest: + """Get bottom level branches (stack bottoms and their direct children) as a forest.""" + return BranchesTreeForest( + [ + BranchesTree( + { + bottom.name: ( + bottom, + BranchesTree({b.name: (b, BranchesTree({})) for b in bottom.children}), + ) + } + ) + for bottom in stack.bottoms + ] + ) + + +def load_pr_info_for_forest(forest: BranchesTreeForest): + """Load PR info for all branches in a forest.""" + for b in forest_depth_first(forest): + b.load_pr_info() + + +def get_complete_stack_forest_for_branch(branch: "StackBranch") -> BranchesTreeForest: + """Get the complete stack forest containing the given branch.""" + from stacky.utils.types import STACK_BOTTOMS + # 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)]) diff --git a/src/stacky/stacky.py b/src/stacky/stacky.py deleted file mode 100755 index 8119ac2..0000000 --- a/src/stacky/stacky.py +++ /dev/null @@ -1,2787 +0,0 @@ -#!/usr/bin/env python3 -# PYTHON_ARGCOMPLETE_OK - -# GitHub helper for stacked diffs. -# -# Git maintains all metadata locally. Does everything by forking "git" and "gh" -# commands. -# -# Theory of operation: -# -# Each entry in a stack is a branch, set to track its parent (that is, `git -# config branch..remote` is ".", and `git config branch..merge` is -# "refs/heads/") -# -# For each branch, we maintain a ref (call it PC, for "parent commit") pointing -# 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 -import dataclasses -import json -import logging -import os -import re -import shlex -import subprocess -import sys -import time -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 - -BranchName = NewType("BranchName", str) -PathName = NewType("PathName", str) -Commit = NewType("Commit", str) -CmdArgs = NewType("CmdArgs", List[str]) -StackSubTree = Tuple["StackBranch", "BranchesTree"] -TreeNode = Tuple[BranchName, StackSubTree] -BranchesTree = NewType("BranchesTree", Dict[BranchName, StackSubTree]) -BranchesTreeForest = NewType("BranchesTreeForest", List[BranchesTree]) - -JSON = Union[Dict[str, "JSON"], List["JSON"], str, int, float, bool, None] - - -class PRInfo(TypedDict): - id: str - number: int - state: str - mergeable: str - url: str - title: str - baseRefName: str - headRefName: str - commits: List[Dict[str, str]] - - -@dataclasses.dataclass -class PRInfos: - all: Dict[str, PRInfo] - open: Optional[PRInfo] - - -@dataclasses.dataclass -class BranchNCommit: - branch: BranchName - parent_commit: Optional[str] - - -_LOGGING_FORMAT = "%(asctime)s %(module)s %(levelname)s: %(message)s" - -# 2 minutes ought to be enough for anybody ;-) -MAX_SSH_MUX_LIFETIME = 120 -COLOR_STDOUT: bool = os.isatty(1) -COLOR_STDERR: bool = os.isatty(2) -IS_TERMINAL: bool = os.isatty(1) and os.isatty(2) -CURRENT_BRANCH: BranchName -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" - -LOGLEVELS = { - "critical": logging.CRITICAL, - "error": logging.ERROR, - "warn": logging.WARNING, - "warning": logging.WARNING, - "info": logging.INFO, - "debug": logging.DEBUG, -} - - -@dataclasses.dataclass -class StackyConfig: - skip_confirm: bool = False - change_to_main: bool = False - change_to_adopted: bool = False - share_ssh_session: bool = False - use_merge: bool = False - use_force_push: bool = True - compact_pr_display: bool = False - enable_stack_comment: bool = True - - def read_one_config(self, config_path: str): - rawconfig = configparser.ConfigParser() - rawconfig.read(config_path) - if rawconfig.has_section("UI"): - self.skip_confirm = rawconfig.getboolean("UI", "skip_confirm", fallback=self.skip_confirm) - self.change_to_main = rawconfig.getboolean("UI", "change_to_main", fallback=self.change_to_main) - self.change_to_adopted = rawconfig.getboolean("UI", "change_to_adopted", fallback=self.change_to_adopted) - self.share_ssh_session = rawconfig.getboolean("UI", "share_ssh_session", fallback=self.share_ssh_session) - self.compact_pr_display = rawconfig.getboolean("UI", "compact_pr_display", fallback=self.compact_pr_display) - self.enable_stack_comment = rawconfig.getboolean("UI", "enable_stack_comment", fallback=self.enable_stack_comment) - - if rawconfig.has_section("GIT"): - self.use_merge = 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 - - -def get_config() -> StackyConfig: - global CONFIG - if CONFIG is None: - CONFIG = read_config() - return CONFIG - - -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") - 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) - - return config - - -def fmt(s: str, *args, color: bool = False, fg=None, bg=None, style=None, **kwargs) -> str: - s = colors.color(s, fg=fg, bg=bg, style=style) if color else s - return s.format(*args, **kwargs) - - -def cout(*args, **kwargs): - return sys.stdout.write(fmt(*args, color=COLOR_STDOUT, **kwargs)) - - -def _log(fn, *args, **kwargs): - return fn("%s", fmt(*args, color=COLOR_STDERR, **kwargs)) - - -def debug(*args, **kwargs): - return _log(logging.debug, *args, fg="green", **kwargs) - - -def info(*args, **kwargs): - return _log(logging.info, *args, fg="green", **kwargs) - - -def warning(*args, **kwargs): - return _log(logging.warning, *args, fg="yellow", **kwargs) - - -def error(*args, **kwargs): - return _log(logging.error, *args, fg="red", **kwargs) - - -class ExitException(BaseException): - def __init__(self, fmt, *args, **kwargs): - super().__init__(fmt.format(*args, **kwargs)) - - -def stop_muxed_ssh(remote: str = "origin"): - if get_config().share_ssh_session: - hostish = get_remote_type(remote) - if hostish is not None: - cmd = gen_ssh_mux_cmd() - cmd.append("-O") - cmd.append("exit") - cmd.append(hostish) - subprocess.Popen(cmd, stderr=subprocess.DEVNULL) - - -def die(*args, **kwargs): - # We are taking a wild guess at what is the remote ... - # TODO (mpatou) fix the assumption about the remote - stop_muxed_ssh() - raise ExitException(*args, **kwargs) - - -def _check_returncode(sp: subprocess.CompletedProcess, cmd: CmdArgs): - rc = sp.returncode - if rc == 0: - return - stderr = sp.stderr.decode("UTF-8") - if rc < 0: - die("Killed by signal {}: {}. Stderr was:\n{}", -rc, shlex.join(cmd), stderr) - else: - die("Exited with status {}: {}. Stderr was:\n{}", rc, shlex.join(cmd), stderr) - - -def run_multiline(cmd: CmdArgs, *, check: bool = True, null: bool = True, out: bool = False) -> Optional[str]: - debug("Running: {}", shlex.join(cmd)) - sys.stdout.flush() - sys.stderr.flush() - sp = subprocess.run( - cmd, - stdout=1 if out else subprocess.PIPE, - stderr=subprocess.PIPE, - ) - if check: - _check_returncode(sp, cmd) - rc = sp.returncode - if rc != 0: - return None - if sp.stdout is None: - return "" - return sp.stdout.decode("UTF-8") - - -def run_always_return(cmd: CmdArgs, **kwargs) -> str: - out = run(cmd, **kwargs) - assert out is not None - return out - - -def run(cmd: CmdArgs, **kwargs) -> Optional[str]: - out = run_multiline(cmd, **kwargs) - return None if out is None else out.strip() - - -def remove_prefix(s: str, prefix: str) -> str: - if not s.startswith(prefix): - die('Invalid string "{}": expected prefix "{}"', s, prefix) - return s[len(prefix) :] # noqa: E203 - - -def get_current_branch() -> Optional[BranchName]: - s = run(CmdArgs(["git", "symbolic-ref", "-q", "HEAD"])) - if s is not None: - return BranchName(remove_prefix(s, "refs/heads/")) - return None - - -def get_all_branches() -> List[BranchName]: - branches = run_multiline(CmdArgs(["git", "for-each-ref", "--format", "%(refname:short)", "refs/heads"])) - assert branches is not None - 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 - """ - branches = get_all_branches() - candiates = set() - for b in branches: - if b in STACK_BOTTOMS: - candiates.add(b) - - if len(candiates) == 1: - return candiates.pop() - - -def get_stack_parent_branch(branch: BranchName) -> Optional[BranchName]: # type: ignore [return] - if branch in STACK_BOTTOMS: - return None - 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) - - -def get_top_level_dir() -> PathName: - p = run_always_return(CmdArgs(["git", "rev-parse", "--show-toplevel"])) - return PathName(p) - - -def get_stack_parent_commit(branch: BranchName) -> Optional[Commit]: # type: ignore [return] - c = run( - CmdArgs(["git", "rev-parse", "refs/stack-parent/{}".format(branch)]), - check=False, - ) - - if c is not None: - return Commit(c) - - -def get_commit(branch: BranchName) -> Commit: # type: ignore [return] - c = run_always_return(CmdArgs(["git", "rev-parse", "refs/heads/{}".format(branch)]), check=False) - return Commit(c) - - -def get_pr_info(branch: BranchName, *, full: bool = False) -> PRInfos: - fields = [ - "id", - "number", - "state", - "mergeable", - "url", - "title", - "baseRefName", - "headRefName", - "reviewDecision", - "reviewRequests", - "isDraft", - ] - if full: - fields += ["commits"] - data = json.loads( - run_always_return( - CmdArgs( - [ - "gh", - "pr", - "list", - "--json", - ",".join(fields), - "--state", - "all", - "--head", - branch, - ] - ) - ) - ) - raw_infos: List[PRInfo] = data - - infos: Dict[str, PRInfo] = {info["id"]: info for info in raw_infos} - open_prs: List[PRInfo] = [info for info in infos.values() if info["state"] == "OPEN"] - if len(open_prs) > 1: - die( - "Branch {} has more than one open PR: {}", - branch, - ", ".join([str(pr) for pr in open_prs]), - ) # type: ignore[arg-type] - return PRInfos(infos, open_prs[0] if open_prs else None) - - -# (remote, remote_branch, remote_branch_commit) -def get_remote_info(branch: BranchName) -> Tuple[str, BranchName, Optional[Commit]]: - if branch not in STACK_BOTTOMS: - remote = run(CmdArgs(["git", "config", "branch.{}.remote".format(branch)]), check=False) - if remote != ".": - die("Misconfigured branch {}: remote {}", branch, remote) - - # TODO(tudor): Maybe add a way to change these. - remote = "origin" - remote_branch = branch - - remote_commit = run( - CmdArgs(["git", "rev-parse", "refs/remotes/{}/{}".format(remote, remote_branch)]), - check=False, - ) - - # TODO(mpatou): do something when remote_commit is none - commit = None - if remote_commit is not None: - commit = Commit(remote_commit) - - return (remote, BranchName(remote_branch), commit) - - -class StackBranch: - def __init__( - self, - name: BranchName, - parent: "StackBranch", - parent_commit: Commit, - ): - self.name = name - self.parent = parent - self.parent_commit = parent_commit - self.children: set["StackBranch"] = set() - self.commit = get_commit(name) - self.remote, self.remote_branch, self.remote_commit = get_remote_info(name) - self.pr_info: Dict[str, PRInfo] = {} - self.open_pr_info: Optional[PRInfo] = None - self._pr_info_loaded = False - - def is_synced_with_parent(self): - return self.parent is None or self.parent_commit == self.parent.commit - - def is_synced_with_remote(self): - return self.commit == self.remote_commit - - def __repr__(self): - return f"StackBranch: {self.name} {len(self.children)} {self.commit}" - - def load_pr_info(self): - if not self._pr_info_loaded: - self._pr_info_loaded = True - pr_infos = get_pr_info(self.name) - # FIXME maybe store the whole object and use it elsewhere - self.pr_info, self.open_pr_info = ( - pr_infos.all, - pr_infos.open, - ) - - -class StackBranchSet: - def __init__(self: "StackBranchSet"): - self.stack: Dict[BranchName, StackBranch] = {} - self.tops: set[StackBranch] = set() - self.bottoms: set[StackBranch] = set() - - def add(self, name: BranchName, **kwargs) -> StackBranch: - if name in self.stack: - s = self.stack[name] - assert s.name == name - for k, v in kwargs.items(): - if getattr(s, k) != v: - die( - "Mismatched stack: {}: {}={}, expected {}", - name, - k, - getattr(s, k), - v, - ) - else: - s = StackBranch(name, **kwargs) - self.stack[name] = s - if s.parent is None: - self.bottoms.add(s) - 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 - - def add_child(self, s: StackBranch, child: StackBranch): - s.children.add(child) - self.tops.discard(s) - - -def load_stack_for_given_branch( - stack: StackBranchSet, branch: BranchName, *, check: bool = True -) -> Tuple[Optional[StackBranch], List[BranchName]]: - """Given a stack of branch and a branch name, - update the stack with all the parents of the specified branch - if the branch is part of an existing stack. - Return also a list of BranchName of all the branch bellow the specified one - """ - branches: List[BranchNCommit] = [] - while branch not in STACK_BOTTOMS: - parent = get_stack_parent_branch(branch) - parent_commit = get_stack_parent_commit(branch) - branches.append(BranchNCommit(branch, parent_commit)) - if not parent or not parent_commit: - if check: - die("Branch is not in a stack: {}", branch) - return None, [b.branch for b in branches] - branch = parent - - branches.append(BranchNCommit(branch, None)) - top = None - for b in reversed(branches): - n = stack.add( - b.branch, - parent=top, - parent_commit=b.parent_commit, - ) - if top: - stack.add_child(top, n) - top = n - - 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: - b = all_branches.pop() - top, branches = load_stack_for_given_branch(stack, b, check=False) - all_branches -= set(branches) - if top is None: - if len(branches) > 1: - # Incomplete (broken) stack - warning("Broken stack: {}", " -> ".join(branches)) - continue - if b == CURRENT_BRANCH: - current_branch_top = top - return current_branch_top - - -def make_tree_node(b: StackBranch) -> TreeNode: - return (b.name, (b, make_subtree(b))) - - -def make_subtree(b) -> BranchesTree: - return BranchesTree(dict(make_tree_node(c) for c in sorted(b.children, key=lambda x: x.name))) - - -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 - # TODO: Align things so that we have the same prefix length ? - if not b.is_synced_with_parent(): - prefix += fmt("!", color=colorize, fg="yellow") - severity = max(severity, 2) - if not b.is_synced_with_remote(): - prefix += fmt("~", color=colorize, fg="yellow") - if b.name == CURRENT_BRANCH: - prefix += fmt("*", color=colorize, fg="cyan") - else: - severity = max(severity, 1) - if prefix: - prefix += " " - fg = ["cyan", "green", "yellow", "red"][severity] - suffix = "" - if b.open_pr_info: - suffix += " " - # 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 - - -def format_tree(tree: BranchesTree, *, colorize: bool = False): - return { - format_name(branch, colorize=colorize): format_tree(children, colorize=colorize) - for branch, children in tree.values() - } - - -# Print upside down, to match our "upstack" / "downstack" nomenclature -_ASCII_TREE_BOX = { - "UP_AND_RIGHT": "\u250c", - "HORIZONTAL": "\u2500", - "VERTICAL": "\u2502", - "VERTICAL_AND_RIGHT": "\u251c", -} -_ASCII_TREE_STYLE = asciitree.drawing.BoxStyle(gfx=_ASCII_TREE_BOX) -ASCII_TREE = asciitree.LeftAligned(draw=_ASCII_TREE_STYLE) - - -def print_tree(tree: BranchesTree): - global ASCII_TREE - s = ASCII_TREE(format_tree(tree, colorize=COLOR_STDOUT)) - lines = s.split("\n") - print("\n".join(reversed(lines))) - - -def print_forest(trees: List[BranchesTree]): - for i, t in enumerate(trees): - if i != 0: - print() - print_tree(t) - - -def get_all_stacks_as_forest(stack: StackBranchSet) -> BranchesTreeForest: - return BranchesTreeForest([make_tree(b) for b in stack.bottoms]) - - -def get_current_stack_as_forest(stack: StackBranchSet): - b = stack.stack[CURRENT_BRANCH] - d: BranchesTree = make_tree(b) - b = b.parent - while b: - d = BranchesTree({b.name: (b, d)}) - b = b.parent - return [d] - - -def get_current_upstack_as_forest(stack: StackBranchSet) -> BranchesTreeForest: - b = stack.stack[CURRENT_BRANCH] - return BranchesTreeForest([make_tree(b)]) - - -def get_current_downstack_as_forest(stack: StackBranchSet) -> BranchesTreeForest: - b = stack.stack[CURRENT_BRANCH] - d: BranchesTree = BranchesTree({}) - while b: - d = BranchesTree({b.name: (b, d)}) - b = b.parent - return BranchesTreeForest([d]) - - -def init_git(): - push_default = run(["git", "config", "remote.pushDefault"], check=False) - if push_default is not None: - die("`git config remote.pushDefault` may not be set") - auth_status = run(["gh", "auth", "status"], check=False) - if auth_status is None: - die("`gh` authentication failed") - global CURRENT_BRANCH - CURRENT_BRANCH = get_current_branch() - - -def forest_depth_first( - forest: BranchesTreeForest, -) -> Generator[StackBranch, None, None]: - for tree in forest: - for b in depth_first(tree): - yield b - - -def depth_first(tree: BranchesTree) -> Generator[StackBranch, None, None]: - # This is for the regular forest - for _, (branch, children) in tree.items(): - yield branch - for b in depth_first(children): - yield b - - -def menu_choose_branch(forest: BranchesTreeForest): - if not IS_TERMINAL: - die("May only choose from menu when using a terminal") - - global ASCII_TREE - s = "" - lines = [] - for tree in forest: - s = ASCII_TREE(format_tree(tree)) - lines += [l.rstrip() for l in s.split("\n")] - lines.reverse() - - initial_index = 0 - for i, l in enumerate(lines): - if "*" in l: # lol - initial_index = i - break - - menu = TerminalMenu(lines, cursor_index=initial_index) - idx = menu.show() - if idx is None: - die("Aborted") - - branches = list(forest_depth_first(forest)) - branches.reverse() - return branches[idx] - - -def load_pr_info_for_forest(forest: BranchesTreeForest): - for b in forest_depth_first(forest): - b.load_pr_info() - - -def cmd_info(stack: StackBranchSet, args): - forest = get_all_stacks_as_forest(stack) - if args.pr: - load_pr_info_for_forest(forest) - 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) - - -def cmd_branch_up(stack: StackBranchSet, args): - b = stack.stack[CURRENT_BRANCH] - if not b.children: - info("Branch {} is already at the top of the stack", CURRENT_BRANCH) - return - if len(b.children) > 1: - if not IS_TERMINAL: - die( - "Branch {} has multiple children: {}", - CURRENT_BRANCH, - ", ".join(c.name for c in b.children), - ) - cout( - "Branch {} has {} children, choose one\n", - CURRENT_BRANCH, - len(b.children), - fg="green", - ) - forest = BranchesTreeForest([BranchesTree({BranchName(c.name): (c, BranchesTree({}))}) for c in b.children]) - child = menu_choose_branch(forest).name - else: - child = next(iter(b.children)).name - checkout(child) - - -def cmd_branch_down(stack: StackBranchSet, args): - b = stack.stack[CURRENT_BRANCH] - if not b.parent: - info("Branch {} is already at the bottom of the stack", CURRENT_BRANCH) - return - checkout(b.parent.name) - - -def create_branch(branch): - run(["git", "checkout", "-b", branch, "--track"], out=True) - - -def cmd_branch_new(stack: StackBranchSet, args): - 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, ""])) - - -def cmd_branch_commit(stack: StackBranchSet, args): - """Create a new branch and commit all changes with the provided message""" - global CURRENT_BRANCH - - # First create the new branch (same logic as cmd_branch_new) - b = stack.stack[CURRENT_BRANCH] - assert b.commit - name = args.name - create_branch(name) - run(CmdArgs(["git", "update-ref", "refs/stack-parent/{}".format(name), b.commit, ""])) - - # Update global CURRENT_BRANCH since we just checked out the new branch - CURRENT_BRANCH = BranchName(name) - - # Reload the stack to include the new branch - load_stack_for_given_branch(stack, CURRENT_BRANCH) - - # Now commit all changes with the provided message (or open editor if no message) - do_commit( - stack, - message=args.message, - amend=False, - allow_empty=False, - edit=True, - add_all=args.add_all, - no_verify=args.no_verify, - ) - - -def cmd_branch_checkout(stack: StackBranchSet, args): - branch_name = args.name - if branch_name is None: - forest = get_all_stacks_as_forest(stack) - branch_name = menu_choose_branch(forest).name - checkout(branch_name) - - -def cmd_stack_info(stack: StackBranchSet, args): - forest = get_current_stack_as_forest(stack) - if args.pr: - load_pr_info_for_forest(forest) - print_forest(forest) - - -def cmd_stack_checkout(stack: StackBranchSet, args): - forest = get_current_stack_as_forest(stack) - branch_name = menu_choose_branch(forest).name - checkout(branch_name) - - -def prompt(message: str, default_value: Optional[str]) -> str: - cout(message) - if default_value is not None: - cout("({})", default_value, fg="gray") - cout(" ") - while True: - sys.stderr.flush() - r = input().strip() - - if len(r) > 0: - return r - if default_value: - return default_value - - -def confirm(msg: str = "Proceed?"): - if get_config().skip_confirm: - return - if not os.isatty(0): - die("Standard input is not a terminal, use --force option to force action") - print() - while True: - cout("{} [yes/no] ", msg, fg="yellow") - sys.stderr.flush() - r = input().strip().lower() - if r == "yes" or r == "y": - break - if r == "no": - die("Not confirmed") - cout("Please answer yes or no\n", fg="red") - - -def find_reviewers(b: StackBranch) -> Optional[List[str]]: - out = run_multiline( - CmdArgs( - [ - "git", - "log", - "--pretty=format:%b", - "-1", - f"{b.name}", - ] - ), - ) - assert out is not None - for l in out.split("\n"): - reviewer_match = re.match(r"^reviewers?\s*:\s*(.*)", l, re.I) - if reviewer_match: - reviewers = reviewer_match.group(1).split(",") - logging.debug(f"Found the following reviewers: {', '.join(reviewers)}") - return reviewers - return None - - -def find_issue_marker(name: str) -> Optional[str]: - match = re.search(r"(?:^|[_-])([A-Z]{3,}[_-]?\d{2,})($|[_-].*)", name) - if match: - res = match.group(1) - if "_" in res: - return res.replace("_", "-") - if not "-" in res: - newmatch = re.match(r"(...)(\d+)", res) - assert newmatch is not None - return f"{newmatch.group(1)}-{newmatch.group(2)}" - return res - - return None - - -def create_gh_pr(b: StackBranch, prefix: str): - cout("Creating PR for {}\n", b.name, fg="green") - parent_prefix = "" - if b.parent.name not in STACK_BOTTOMS: - # you are pushing a sub stack, there is no way we can make it work - # accross repo so we will push within your own clone - prefix = "" - cmd = [ - "gh", - "pr", - "create", - "--head", - f"{prefix}{b.name}", - "--base", - f"{parent_prefix}{b.parent.name}", - ] - reviewers = find_reviewers(b) - issue_id = find_issue_marker(b.name) - if issue_id: - out = run_multiline( - CmdArgs(["git", "log", "--pretty=oneline", f"{b.parent.name}..{b.name}"]), - ) - title = f"[{issue_id}] " - # Just one line (hence 2 elements with the last one being an empty string when we - # split on "\"n ? - # Then use the title of the commit as the title of the PR - if out is not None and len(out.split("\n")) == 2: - out = run( - CmdArgs( - [ - "git", - "log", - "--pretty=format:%s", - "-1", - f"{b.name}", - ] - ), - out=False, - ) - if out is None: - out = "" - if b.name not in out: - title += out - else: - title = out - - title = prompt( - (fmt("? ", color=COLOR_STDOUT, fg="green") + fmt("Title ", color=COLOR_STDOUT, style="bold", fg="white")), - title, - ) - cmd.extend(["--title", title.strip()]) - if reviewers: - logging.debug(f"Adding {len(reviewers)} reviewer(s) to the review") - for r in reviewers: - r = r.strip() - r = r.replace("#", "rockset/") - if len(r) > 0: - cmd.extend(["--reviewer", r]) - - run( - CmdArgs(cmd), - out=True, - ) - - -def generate_stack_string(forest: BranchesTreeForest, current_branch: StackBranch) -> str: - """Generate a string representation of the PR stack""" - stack_lines = [] - - def add_branch_to_stack(b: StackBranch, depth: int): - if b.name in STACK_BOTTOMS: - return - - indent = " " * depth - pr_info = "" - if b.open_pr_info: - pr_number = b.open_pr_info['number'] - - # Add approval status emoji using same logic as stacky inbox - status_emoji = get_pr_status_emoji(b.open_pr_info) - pr_info = f" (#{pr_number}{status_emoji})" - - # Add arrow indicator for current branch (the one this PR represents) - current_indicator = " ← (CURRENT PR)" if b.name == current_branch.name else "" - - stack_lines.append(f"{indent}- {b.name}{pr_info}{current_indicator}") - - def traverse_tree(tree: BranchesTree, depth: int): - for _, (branch, children) in tree.items(): - add_branch_to_stack(branch, depth) - traverse_tree(children, depth + 1) - - for tree in forest: - traverse_tree(tree, 0) - - if not stack_lines: - return "" - - return "\n".join([ - "", - "**Stack:**", - *stack_lines, - "" - ]) - - -def get_branch_depth(branch: StackBranch, forest: BranchesTreeForest) -> int: - """Calculate the depth of a branch in the stack""" - depth = 0 - b = branch - while b.parent and b.parent.name not in STACK_BOTTOMS: - depth += 1 - b = b.parent - return depth - - -def extract_stack_comment(body: str) -> str: - """Extract existing stack comment from PR body""" - if not body: - return "" - - # Look for the stack comment pattern using HTML comments as sentinels - import re - pattern = r'.*?' - match = re.search(pattern, body, re.DOTALL) - - if match: - return match.group(0).strip() - return "" - - -def get_complete_stack_forest_for_branch(branch: StackBranch) -> BranchesTreeForest: - """Get the complete stack forest containing the given branch""" - # Find the root of the stack - root = branch - while root.parent and root.parent.name not in STACK_BOTTOMS: - root = root.parent - - # Create a forest with just this root's complete tree - return BranchesTreeForest([make_tree(root)]) - - -def add_or_update_stack_comment(branch: StackBranch, complete_forest: BranchesTreeForest): - """Add or update stack comment in PR body using a pre-computed complete forest""" - if not branch.open_pr_info: - return - - pr_number = branch.open_pr_info["number"] - - # Get current PR body - pr_data = json.loads( - run_always_return( - CmdArgs([ - "gh", "pr", "view", str(pr_number), - "--json", "body" - ]) - ) - ) - - current_body = pr_data.get("body", "") - stack_string = generate_stack_string(complete_forest, branch) - - if not stack_string: - return - - existing_stack = extract_stack_comment(current_body) - - if not existing_stack: - # No existing stack comment, add one - if current_body: - new_body = f"{current_body}\n\n{stack_string}" - else: - new_body = stack_string - - cout("Adding stack comment to PR #{}\n", pr_number, fg="green") - run(CmdArgs([ - "gh", "pr", "edit", str(pr_number), - "--body", new_body - ]), out=True) - else: - # Verify existing stack comment is correct - if existing_stack != stack_string: - # Update the stack comment - updated_body = current_body.replace(existing_stack, stack_string) - - cout("Updating stack comment in PR #{}\n", pr_number, fg="yellow") - run(CmdArgs([ - "gh", "pr", "edit", str(pr_number), - "--body", updated_body - ]), out=True) - else: - cout("✓ Stack comment in PR #{} is already correct\n", pr_number, fg="green") - - - - -def do_push( - forest: BranchesTreeForest, - *, - force: bool = False, - pr: bool = False, - remote_name: str = "origin", -): - if pr: - load_pr_info_for_forest(forest) - print_forest(forest) - for b in forest_depth_first(forest): - if not b.is_synced_with_parent(): - die( - "Branch {} is not synced with parent {}, sync first", - b.name, - b.parent.name, - ) - - # (branch, push, pr_action) - PR_NONE = 0 - PR_FIX_BASE = 1 - PR_CREATE = 2 - actions = [] - for b in forest_depth_first(forest): - if not b.parent: - cout("✓ Not pushing base branch {}\n", b.name, fg="green") - continue - - push = False - if b.is_synced_with_remote(): - cout( - "✓ Not pushing branch {}, synced with remote {}/{}\n", - b.name, - b.remote, - b.remote_branch, - fg="green", - ) - else: - cout("- Will push branch {} to {}/{}\n", b.name, b.remote, b.remote_branch) - push = True - - pr_action = PR_NONE - if pr: - if b.open_pr_info: - expected_base = b.parent.name - if b.open_pr_info["baseRefName"] != expected_base: - cout( - "- Branch {} already has open PR #{}; will change PR base from {} to {}\n", - b.name, - b.open_pr_info["number"], - b.open_pr_info["baseRefName"], - expected_base, - ) - pr_action = PR_FIX_BASE - else: - cout( - "✓ Branch {} already has open PR #{}\n", - b.name, - b.open_pr_info["number"], - fg="green", - ) - else: - cout("- Will create PR for branch {}\n", b.name) - pr_action = PR_CREATE - - if not push and pr_action == PR_NONE: - continue - - actions.append((b, push, pr_action)) - - if actions and not force: - confirm() - - # Figure out if we need to add a prefix to the branch - # ie. user:foo - # We should call gh repo set-default before doing that - val = run(CmdArgs(["git", "config", f"remote.{remote_name}.gh-resolved"]), check=False) - if val is not None and "/" in val: - # If there is a "/" in the gh-resolved it means that the repo where - # the should be created is not the same as the one where the push will - # be made, we need to add a prefix to the branch in the gh pr command - val = run_always_return(CmdArgs(["git", "config", f"remote.{remote_name}.url"])) - prefix = f'{val.split(":")[1].split("/")[0]}:' - else: - prefix = "" - muxed = False - for b, push, pr_action in actions: - if push: - if not muxed: - start_muxed_ssh(remote_name) - muxed = True - # Try to run pre-push before muxing ... - # 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(cmd_args), - out=True, - ) - if pr_action == PR_FIX_BASE: - cout("Fixing PR base for {}\n", b.name, fg="green") - assert b.open_pr_info is not None - run( - CmdArgs( - [ - "gh", - "pr", - "edit", - str(b.open_pr_info["number"]), - "--base", - b.parent.name, - ] - ), - out=True, - ) - elif pr_action == PR_CREATE: - create_gh_pr(b, prefix) - - # Handle stack comments for PRs - if pr and get_config().enable_stack_comment: - # Reload PR info to include newly created PRs - load_pr_info_for_forest(forest) - - # Get complete forests for all branches with PRs (grouped by stack root) - complete_forests_by_root = {} - branches_with_prs = [b for b in forest_depth_first(forest) if b.open_pr_info] - - for b in branches_with_prs: - # Find root branch - root = b - while root.parent and root.parent.name not in STACK_BOTTOMS: - root = root.parent - - root_name = root.name - if root_name not in complete_forests_by_root: - # Create complete forest for this root and load PR info once - complete_forest = get_complete_stack_forest_for_branch(b) - load_pr_info_for_forest(complete_forest) - complete_forests_by_root[root_name] = complete_forest - - # Now update stack comments using the cached complete forests - for b in branches_with_prs: - root = b - while root.parent and root.parent.name not in STACK_BOTTOMS: - root = root.parent - - complete_forest = complete_forests_by_root[root.name] - add_or_update_stack_comment(b, complete_forest) - - stop_muxed_ssh(remote_name) - - -def cmd_stack_push(stack: StackBranchSet, args): - do_push( - get_current_stack_as_forest(stack), - force=args.force, - pr=args.pr, - remote_name=args.remote_name, - ) - - -def do_sync(forest: BranchesTreeForest): - print_forest(forest) - - syncs: List[StackBranch] = [] - sync_names: List[BranchName] = [] - syncs_set: set[StackBranch] = set() - for b in forest_depth_first(forest): - if not b.parent: - cout("✓ Not syncing base branch {}\n", b.name, fg="green") - continue - if b.is_synced_with_parent() and not b.parent in syncs_set: - cout( - "✓ Not syncing branch {}, already synced with parent {}\n", - b.name, - b.parent.name, - fg="green", - ) - continue - syncs.append(b) - syncs_set.add(b) - sync_names.append(b.name) - cout("- Will sync branch {} on top of {}\n", b.name, b.parent.name) - - if not syncs: - return - - syncs.reverse() - sync_names.reverse() - # TODO: use list(syncs_set).reverse() ? - inner_do_sync(syncs, sync_names) - - -def set_parent_commit(branch: BranchName, new_commit: Commit, prev_commit: Optional[str] = None): - cmd = [ - "git", - "update-ref", - "refs/stack-parent/{}".format(branch), - new_commit, - ] - if prev_commit is not None: - cmd.append(prev_commit) - run(CmdArgs(cmd)) - - -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 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) - os.replace(TMP_STATE_FILE, STATE_FILE) # make the write atomic - - b = syncs.pop() - sync_names.pop() - if b.is_synced_with_parent(): - cout("{} is already synced on top of {}\n", b.name, b.parent.name) - continue - if b.parent.commit in get_commits_between(b.parent_commit, b.commit): - cout( - "Recording complete {} of {} on top of {}\n", - sync_type, - b.name, - b.parent.name, - fg="green", - ) - else: - 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 {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) - b.parent_commit = b.parent.commit - run(CmdArgs(["git", "checkout", str(CURRENT_BRANCH)])) - - -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, no_verify=False): - b = stack.stack[CURRENT_BRANCH] - if not b.parent: - die("Do not commit directly on {}", b.name) - if not b.is_synced_with_parent(): - die( - "Branch {} is not synced with parent {}, sync before committing", - 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) - - cmd = ["git", "commit"] - if add_all: - cmd += ["-a"] - if allow_empty: - cmd += ["--allow-empty"] - if no_verify: - cmd += ["--no-verify"] - if amend: - cmd += ["--amend"] - if not edit: - cmd += ["--no-edit"] - elif not edit: - die("--no-edit is only supported with --amend") - if message: - cmd += ["-m", message] - run(CmdArgs(cmd), out=True) - - # Sync everything upstack - b.commit = get_commit(b.name) - do_sync(get_current_upstack_as_forest(stack)) - - -def cmd_commit(stack: StackBranchSet, args): - do_commit( - stack, - message=args.message, - amend=args.amend, - allow_empty=args.allow_empty, - edit=not args.no_edit, - add_all=args.add_all, - no_verify=args.no_verify, - ) - - -def cmd_amend(stack: StackBranchSet, args): - do_commit(stack, amend=True, edit=False, no_verify=args.no_verify) - - -def cmd_upstack_info(stack: StackBranchSet, args): - forest = get_current_upstack_as_forest(stack) - if args.pr: - load_pr_info_for_forest(forest) - print_forest(forest) - - -def cmd_upstack_push(stack: StackBranchSet, args): - do_push( - get_current_upstack_as_forest(stack), - force=args.force, - pr=args.pr, - remote_name=args.remote_name, - ) - - -def cmd_upstack_sync(stack: StackBranchSet, args): - do_sync(get_current_upstack_as_forest(stack)) - - -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 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 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): - if ub == target: - die("Target branch {} is upstack of {}", target.name, b.name) - b.parent = target - set_parent(b.name, target.name) - - 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: - load_pr_info_for_forest(forest) - print_forest(forest) - - -def cmd_downstack_push(stack: StackBranchSet, args): - do_push( - get_current_downstack_as_forest(stack), - force=args.force, - pr=args.pr, - remote_name=args.remote_name, - ) - - -def cmd_downstack_sync(stack: StackBranchSet, args): - do_sync(get_current_downstack_as_forest(stack)) - - -def get_bottom_level_branches_as_forest(stack: StackBranchSet) -> BranchesTreeForest: - return BranchesTreeForest( - [ - BranchesTree( - { - bottom.name: ( - bottom, - BranchesTree({b.name: (b, BranchesTree({})) for b in bottom.children}), - ) - } - ) - for bottom in stack.bottoms - ] - ) - - -def get_remote_type(remote: str = "origin") -> Optional[str]: - out = run_always_return(CmdArgs(["git", "remote", "-v"])) - for l in out.split("\n"): - match = re.match(r"^{}\s+(?:ssh://)?([^/]*):(?!//).*\s+\(push\)$".format(remote), l) - if match: - sshish_host = match.group(1) - return sshish_host - - return None - - -def gen_ssh_mux_cmd() -> List[str]: - args = [ - "ssh", - "-o", - "ControlMaster=auto", - "-o", - f"ControlPersist={MAX_SSH_MUX_LIFETIME}", - "-o", - "ControlPath=~/.ssh/stacky-%C", - ] - - return args - - -def start_muxed_ssh(remote: str = "origin"): - if not get_config().share_ssh_session: - return - hostish = get_remote_type(remote) - if hostish is not None: - info("Creating a muxed ssh connection") - cmd = gen_ssh_mux_cmd() - os.environ["GIT_SSH_COMMAND"] = " ".join(cmd) - cmd.append("-MNf") - cmd.append(hostish) - # We don't want to use the run() wrapper because - # we don't want to wait for the process to finish - - p = subprocess.Popen(cmd, stderr=subprocess.PIPE) - # Wait a little bit for the connection to establish - # before carrying on - while p.poll() is None: - time.sleep(1) - if p.returncode != 0: - if p.stderr is not None: - error = p.stderr.read() - else: - error = b"unknown" - die(f"Failed to start ssh muxed connection, error was: {error.decode('utf-8').strip()}") - - -def get_branches_to_delete(forest: BranchesTreeForest) -> List[StackBranch]: - deletes = [] - for b in forest_depth_first(forest): - if not b.parent or b.open_pr_info: - continue - for pr_info in b.pr_info.values(): - if pr_info["state"] != "MERGED": - continue - cout( - "- Will delete branch {}, PR #{} merged into {}\n", - b.name, - pr_info["number"], - b.parent.name, - ) - deletes.append(b) - for c in b.children: - cout( - "- Will reparent branch {} onto {}\n", - c.name, - b.parent.name, - ) - break - return deletes - - -def delete_branches(stack: StackBranchSet, deletes: List[StackBranch]): - global CURRENT_BRANCH - # Make sure we're not trying to delete the current branch - for b in deletes: - for c in b.children: - info("Reparenting {} onto {}", c.name, b.parent.name) - c.parent = b.parent - set_parent(c.name, b.parent.name) - info("Deleting {}", b.name) - if b.name == CURRENT_BRANCH: - new_branch = next(iter(stack.bottoms)) - info("About to delete current branch, switching to {}", new_branch.name) - run(CmdArgs(["git", "checkout", new_branch.name])) - CURRENT_BRANCH = new_branch.name - run(CmdArgs(["git", "branch", "-D", b.name])) - - -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 bottom not in stack.stack or bottom not in existing_branches: - ref = "refs/stacky-bottom-branch/{}".format(bottom) - 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 or br not in existing_branches: - ref = "refs/stack-parent/{}".format(br) - 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])) - - -def cmd_update(stack: StackBranchSet, args): - remote = "origin" - start_muxed_ssh(remote) - info("Fetching from {}", remote) - run(CmdArgs(["git", "fetch", remote])) - - # TODO(tudor): We should rebase instead of silently dropping - # everything you have on local master. Oh well. - global CURRENT_BRANCH - for b in stack.bottoms: - run( - CmdArgs( - [ - "git", - "update-ref", - "refs/heads/{}".format(b.name), - "refs/remotes/{}/{}".format(remote, b.remote_branch), - ] - ) - ) - if b.name == CURRENT_BRANCH: - run(CmdArgs(["git", "reset", "--hard", "HEAD"])) - - # We treat origin as the source of truth for bottom branches (master), and - # the local repo as the source of truth for everything else. So we can only - # track PR closure for branches that are direct descendants of master. - - info("Checking if any PRs have been merged and can be deleted") - forest = get_bottom_level_branches_as_forest(stack) - load_pr_info_for_forest(forest) - - deletes = get_branches_to_delete(forest) - if deletes and not args.force: - confirm() - - delete_branches(stack, deletes) - stop_muxed_ssh(remote) - - info("Cleaning up refs for non-existent branches") - cleanup_unused_refs(stack) - - -def cmd_import(stack: StackBranchSet, args): - # Importing has to happen based on PR info, rather than local branch - # relationships, as that's the only place Graphite populates. - branch = args.name - branches = [] - bottoms = set(b.name for b in stack.bottoms) - while branch not in bottoms: - pr_info = get_pr_info(branch, full=True) - open_pr = pr_info.open - info("Getting PR information for {}", branch) - if open_pr is None: - die("Branch {} has no open PR", branch) - # Never reached because the die but makes mypy happy - assert open_pr is not None - if open_pr["headRefName"] != branch: - die( - "Branch {} is misconfigured: PR #{} head is {}", - branch, - open_pr["number"], - open_pr["headRefName"], - ) - if not open_pr["commits"]: - die("PR #{} has no commits", open_pr["number"]) - first_commit = open_pr["commits"][0]["oid"] - parent_commit = Commit(run_always_return(CmdArgs(["git", "rev-parse", "{}^".format(first_commit)]))) - next_branch = open_pr["baseRefName"] - info( - "Branch {}: PR #{}, parent is {} at commit {}", - branch, - open_pr["number"], - next_branch, - parent_commit, - ) - branches.append((branch, parent_commit)) - branch = next_branch - - if not branches: - return - - base_branch = branch - branches.reverse() - - for b, parent_commit in branches: - cout( - "- Will set parent of {} to {} at commit {}\n", - b, - branch, - parent_commit, - ) - branch = b - - if not args.force: - confirm() - - branch = base_branch - for b, parent_commit in branches: - set_parent(b, branch, set_origin=True) - set_parent_commit(b, parent_commit) - branch = b - - -def get_merge_base(b1: BranchName, b2: BranchName): - return run(CmdArgs(["git", "merge-base", str(b1), str(b2)])) - - -def cmd_adopt(stack: StackBranch, args): - """ - Adopt a branch that is based on the current branch (which must be a - valid stack bottom or the stack bottom (master or main) will be used - if change_to_main option is set in the config file - """ - 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() - - if get_config().change_to_main and main_branch is not None: - run(CmdArgs(["git", "checkout", main_branch])) - CURRENT_BRANCH = main_branch - else: - die( - "The current branch {} must be a valid stack bottom: {}", - 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) - if get_config().change_to_adopted: - run(CmdArgs(["git", "checkout", branch])) - - -def cmd_land(stack: StackBranchSet, args): - forest = get_current_downstack_as_forest(stack) - assert len(forest) == 1 - branches = [] - p = forest[0] - while p: - assert len(p) == 1 - _, (b, p) = next(iter(p.items())) - branches.append(b) - assert branches - assert branches[0] in stack.bottoms - if len(branches) == 1: - die("May not land {}", branches[0].name) - - b = branches[1] - if not b.is_synced_with_parent(): - die( - "Branch {} is not synced with parent {}, sync before landing", - b.name, - b.parent.name, - ) - if not b.is_synced_with_remote(): - die( - "Branch {} is not synced with remote branch, push local changes before landing", - b.name, - ) - - b.load_pr_info() - pr = b.open_pr_info - if not pr: - die("Branch {} does not have an open PR", b.name) - assert pr is not None - - if pr["mergeable"] != "MERGEABLE": - die( - "PR #{} for branch {} is not mergeable: {}", - pr["number"], - b.name, - pr["mergeable"], - ) - - if len(branches) > 2: - cout( - "The `land` command only lands the bottom-most branch {}; the current stack has {} branches, ending with {}\n", - b.name, - len(branches) - 1, - CURRENT_BRANCH, - fg="yellow", - ) - - msg = fmt("- Will land PR #{} (", pr["number"], color=COLOR_STDOUT) - msg += fmt("{}", pr["url"], color=COLOR_STDOUT, fg="blue") - msg += fmt(") for branch {}", b.name, color=COLOR_STDOUT) - msg += fmt(" into branch {}\n", b.parent.name, color=COLOR_STDOUT) - sys.stdout.write(msg) - - if not args.force: - confirm() - - v = run(CmdArgs(["git", "rev-parse", b.name])) - assert v is not None - head_commit = Commit(v) - cmd = CmdArgs(["gh", "pr", "merge", b.name, "--squash", "--match-head-commit", head_commit]) - if args.auto: - cmd.append("--auto") - run(cmd, out=True) - cout("\n✓ Success! Run `stacky update` to update local state.\n", fg="green") - - -def edit_pr_description(pr): - """Edit a PR's description using the user's default editor""" - import tempfile - - cout("Editing PR #{} - {}\n", pr["number"], pr["title"], fg="green") - cout("Current description:\n", fg="yellow") - current_body = pr.get("body", "") - if current_body: - cout("{}\n\n", current_body, fg="gray") - else: - cout("(No description)\n\n", fg="gray") - - # Create a temporary file with the current description - with tempfile.NamedTemporaryFile(mode='w+', suffix='.md', delete=False) as temp_file: - temp_file.write(current_body or "") - temp_file_path = temp_file.name - - try: - # Get the user's preferred editor - editor = os.environ.get('EDITOR', 'vim') - - # Open the editor - result = subprocess.run([editor, temp_file_path]) - if result.returncode != 0: - cout("Editor exited with error, not updating PR description.\n", fg="red") - return - - # Read the edited content - with open(temp_file_path, 'r') as temp_file: - new_body = temp_file.read().strip() - - # Normalize both original and new content for comparison - original_content = (current_body or "").strip() - new_content = new_body.strip() - - # Check if the content actually changed - if new_content == original_content: - cout("No changes made to PR description.\n", fg="yellow") - return - - # Update the PR description using gh CLI - cout("Updating PR description...\n", fg="green") - run(CmdArgs([ - "gh", "pr", "edit", str(pr["number"]), - "--body", new_body - ]), out=True) - - cout("✓ Successfully updated PR #{} description\n", pr["number"], fg="green") - - # Update the PR object for display consistency - pr["body"] = new_body - - except Exception as e: - cout("Error editing PR description: {}\n", str(e), fg="red") - finally: - # Clean up the temporary file - try: - os.unlink(temp_file_path) - except OSError: - pass - - -def cmd_inbox(stack: StackBranchSet, args): - """List all active GitHub pull requests for the current user""" - fields = [ - "number", - "title", - "headRefName", - "baseRefName", - "state", - "url", - "createdAt", - "updatedAt", - "author", - "reviewDecision", - "reviewRequests", - "mergeable", - "mergeStateStatus", - "statusCheckRollup", - "isDraft", - "body" - ] - - # Get all open PRs authored by the current user - my_prs_data = json.loads( - run_always_return( - CmdArgs( - [ - "gh", - "pr", - "list", - "--json", - ",".join(fields), - "--state", - "open", - "--author", - "@me" - ] - ) - ) - ) - - # Get all open PRs where current user is requested as reviewer - review_prs_data = json.loads( - run_always_return( - CmdArgs( - [ - "gh", - "pr", - "list", - "--json", - ",".join(fields), - "--state", - "open", - "--search", - "review-requested:@me" - ] - ) - ) - ) - - # Categorize my PRs based on review status - waiting_on_me = [] - waiting_on_review = [] - approved = [] - - for pr in my_prs_data: - if pr.get("isDraft", False): - # Draft PRs are always waiting on the author (me) - waiting_on_me.append(pr) - elif pr["reviewDecision"] == "APPROVED": - approved.append(pr) - elif pr["reviewRequests"] and len(pr["reviewRequests"]) > 0: - waiting_on_review.append(pr) - else: - # No pending review requests, likely needs changes or author action - waiting_on_me.append(pr) - - # Sort all lists by updatedAt in descending order (most recent first) - waiting_on_me.sort(key=lambda pr: pr["updatedAt"], reverse=True) - waiting_on_review.sort(key=lambda pr: pr["updatedAt"], reverse=True) - approved.sort(key=lambda pr: pr["updatedAt"], reverse=True) - review_prs_data.sort(key=lambda pr: pr["updatedAt"], reverse=True) - - def get_check_status(pr): - """Get a summary of merge check status""" - if not pr.get("statusCheckRollup") or len(pr.get("statusCheckRollup")) == 0: - return "", "gray" - - rollup = pr["statusCheckRollup"] - - # statusCheckRollup is a list of checks, determine overall state - states = [] - for check in rollup: - if isinstance(check, dict) and "state" in check: - states.append(check["state"]) - - if not states: - return "", "gray" - - # Determine overall status based on individual check states - if "FAILURE" in states or "ERROR" in states: - return "✗ Checks failed", "red" - elif "PENDING" in states or "QUEUED" in states: - return "⏳ Checks running", "yellow" - elif all(state == "SUCCESS" for state in states): - return "✓ Checks passed", "green" - else: - return f"Checks mixed", "yellow" - - def display_pr_compact(pr, show_author=False): - """Display a single PR in compact format""" - check_text, check_color = get_check_status(pr) - - # Create clickable link for PR number - pr_number_text = f"#{pr['number']}" - clickable_number = f"\033]8;;{pr['url']}\033\\\033[96m{pr_number_text}\033[0m\033]8;;\033\\" - cout("{} ", clickable_number) - cout("{} ", pr["title"], fg="white") - cout("({}) ", pr["headRefName"], fg="gray") - - if show_author: - cout("by {} ", pr["author"]["login"], fg="gray") - - if pr.get("isDraft", False): - cout("[DRAFT] ", fg="orange") - - if check_text: - cout("{} ", check_text, fg=check_color) - - cout("Updated: {}\n", pr["updatedAt"][:10], fg="gray") - - def display_pr_full(pr, show_author=False): - """Display a single PR in full format""" - check_text, check_color = get_check_status(pr) - - # Create clickable link for PR number - pr_number_text = f"#{pr['number']}" - clickable_number = f"\033]8;;{pr['url']}\033\\\033[96m{pr_number_text}\033[0m\033]8;;\033\\" - cout("{} ", clickable_number) - cout("{}\n", pr["title"], fg="white") - cout(" {} -> {}\n", pr["headRefName"], pr["baseRefName"], fg="gray") - - if show_author: - cout(" Author: {}\n", pr["author"]["login"], fg="gray") - - if pr.get("isDraft", False): - cout(" [DRAFT]\n", fg="orange") - - if check_text: - cout(" {}\n", check_text, fg=check_color) - - cout(" {}\n", pr["url"], fg="blue") - cout(" Updated: {}, Created: {}\n\n", pr["updatedAt"][:10], pr["createdAt"][:10], fg="gray") - - def display_pr_list(prs, show_author=False): - """Display a list of PRs in the chosen format""" - for pr in prs: - if args.compact: - display_pr_compact(pr, show_author) - else: - display_pr_full(pr, show_author) - - # Display categorized authored PRs - if waiting_on_me: - cout("Your PRs - Waiting on You:\n", fg="red") - display_pr_list(waiting_on_me) - cout("\n") - - if waiting_on_review: - cout("Your PRs - Waiting on Review:\n", fg="yellow") - display_pr_list(waiting_on_review) - cout("\n") - - if approved: - cout("Your PRs - Approved:\n", fg="green") - display_pr_list(approved) - cout("\n") - - if not my_prs_data: - cout("No active pull requests authored by you.\n", fg="green") - - # Display PRs waiting for review - if review_prs_data: - cout("Pull Requests Awaiting Your Review:\n", fg="yellow") - display_pr_list(review_prs_data, show_author=True) - else: - cout("No pull requests awaiting your review.\n", fg="yellow") - - -def cmd_prs(stack: StackBranchSet, args): - """Interactive PR management - select and edit PR descriptions""" - fields = [ - "number", - "title", - "headRefName", - "baseRefName", - "state", - "url", - "createdAt", - "updatedAt", - "author", - "reviewDecision", - "reviewRequests", - "mergeable", - "mergeStateStatus", - "statusCheckRollup", - "isDraft", - "body" - ] - - # Get all open PRs authored by the current user - my_prs_data = json.loads( - run_always_return( - CmdArgs( - [ - "gh", - "pr", - "list", - "--json", - ",".join(fields), - "--state", - "open", - "--author", - "@me" - ] - ) - ) - ) - - # Get all open PRs where current user is requested as reviewer - review_prs_data = json.loads( - run_always_return( - CmdArgs( - [ - "gh", - "pr", - "list", - "--json", - ",".join(fields), - "--state", - "open", - "--search", - "review-requested:@me" - ] - ) - ) - ) - - # Combine all PRs - all_prs = my_prs_data + review_prs_data - if not all_prs: - cout("No active pull requests found.\n", fg="green") - return - - if not IS_TERMINAL: - die("Interactive PR management requires a terminal") - - # Create simple menu options - menu_options = [] - for pr in all_prs: - # Simple menu line with just PR number and title - menu_options.append(f"#{pr['number']} {pr['title']}") - - menu_options.append("Exit") - - while True: - cout("\nSelect a PR to edit its description:\n", fg="cyan") - menu = TerminalMenu(menu_options, cursor_index=0) - idx = menu.show() - - if idx is None or idx == len(menu_options) - 1: # Exit selected or cancelled - break - - selected_pr = all_prs[idx] - edit_pr_description(selected_pr) - - -def main(): - logging.basicConfig(format=_LOGGING_FORMAT, level=logging.INFO) - try: - parser = ArgumentParser(description="Handle git stacks") - parser.add_argument( - "--log-level", - default="info", - choices=LOGLEVELS.keys(), - help="Set the log level", - ) - parser.add_argument( - "--color", - default="auto", - choices=["always", "auto", "never"], - help="Colorize output and error", - ) - parser.add_argument( - "--remote-name", - "-r", - default="origin", - help="name of the git remote where branches will be pushed", - ) - - subparsers = parser.add_subparsers(required=True, dest="command") - - # continue - continue_parser = subparsers.add_parser("continue", help="Continue previously interrupted command") - continue_parser.set_defaults(func=None) - - # down - down_parser = subparsers.add_parser("down", help="Go down in the current stack (towards master/main)") - down_parser.set_defaults(func=cmd_branch_down) - # up - up_parser = subparsers.add_parser("up", help="Go up in the current stack (away master/main)") - up_parser.set_defaults(func=cmd_branch_up) - # info - info_parser = subparsers.add_parser("info", help="Stack info") - info_parser.add_argument("--pr", action="store_true", help="Get PR info (slow)") - info_parser.set_defaults(func=cmd_info) - - # log - log_parser = subparsers.add_parser("log", help="Show git log with conditional merge handling") - log_parser.set_defaults(func=cmd_log) - - # commit - commit_parser = subparsers.add_parser("commit", help="Commit") - commit_parser.add_argument("-m", help="Commit message", dest="message") - commit_parser.add_argument("--amend", action="store_true", help="Amend last commit") - commit_parser.add_argument("--allow-empty", action="store_true", help="Allow empty commit") - commit_parser.add_argument("--no-edit", action="store_true", help="Skip editor") - commit_parser.add_argument("-a", action="store_true", help="Add all files to commit", dest="add_all") - commit_parser.add_argument("--no-verify", action="store_true", help="Bypass pre-commit and commit-msg hooks") - commit_parser.set_defaults(func=cmd_commit) - - # amend - amend_parser = subparsers.add_parser("amend", help="Shortcut for amending last commit") - amend_parser.add_argument("--no-verify", action="store_true", help="Bypass pre-commit and commit-msg hooks") - amend_parser.set_defaults(func=cmd_amend) - - # branch - branch_parser = subparsers.add_parser("branch", aliases=["b"], help="Operations on branches") - branch_subparsers = branch_parser.add_subparsers(required=True, dest="branch_command") - branch_up_parser = branch_subparsers.add_parser("up", aliases=["u"], help="Move upstack") - branch_up_parser.set_defaults(func=cmd_branch_up) - - branch_down_parser = branch_subparsers.add_parser("down", aliases=["d"], help="Move downstack") - branch_down_parser.set_defaults(func=cmd_branch_down) - - branch_new_parser = branch_subparsers.add_parser("new", aliases=["create"], help="Create a new branch") - branch_new_parser.add_argument("name", help="Branch name") - branch_new_parser.set_defaults(func=cmd_branch_new) - - branch_commit_parser = branch_subparsers.add_parser("commit", help="Create a new branch and commit all changes") - branch_commit_parser.add_argument("name", help="Branch name") - branch_commit_parser.add_argument("-m", help="Commit message", dest="message") - branch_commit_parser.add_argument("-a", action="store_true", help="Add all files to commit", dest="add_all") - branch_commit_parser.add_argument("--no-verify", action="store_true", help="Bypass pre-commit and commit-msg hooks") - branch_commit_parser.set_defaults(func=cmd_branch_commit) - - branch_checkout_parser = branch_subparsers.add_parser("checkout", aliases=["co"], help="Checkout a branch") - branch_checkout_parser.add_argument("name", help="Branch name", nargs="?").completer = branch_name_completer - branch_checkout_parser.set_defaults(func=cmd_branch_checkout) - - # stack - stack_parser = subparsers.add_parser("stack", aliases=["s"], help="Operations on the full current stack") - stack_subparsers = stack_parser.add_subparsers(required=True, dest="stack_command") - - stack_info_parser = stack_subparsers.add_parser("info", aliases=["i"], help="Info for current stack") - stack_info_parser.add_argument("--pr", action="store_true", help="Get PR info (slow)") - stack_info_parser.set_defaults(func=cmd_stack_info) - - stack_push_parser = stack_subparsers.add_parser("push", help="Push") - stack_push_parser.add_argument("--force", "-f", action="store_true", help="Bypass confirmation") - stack_push_parser.add_argument("--no-pr", dest="pr", action="store_false", help="Skip Create PRs") - stack_push_parser.set_defaults(func=cmd_stack_push) - - stack_sync_parser = stack_subparsers.add_parser("sync", help="Sync") - stack_sync_parser.set_defaults(func=cmd_stack_sync) - - stack_checkout_parser = stack_subparsers.add_parser( - "checkout", aliases=["co"], help="Checkout a branch in this stack" - ) - stack_checkout_parser.set_defaults(func=cmd_stack_checkout) - - # upstack - upstack_parser = subparsers.add_parser("upstack", aliases=["us"], help="Operations on the current upstack") - upstack_subparsers = upstack_parser.add_subparsers(required=True, dest="upstack_command") - - upstack_info_parser = upstack_subparsers.add_parser("info", aliases=["i"], help="Info for current upstack") - upstack_info_parser.add_argument("--pr", action="store_true", help="Get PR info (slow)") - upstack_info_parser.set_defaults(func=cmd_upstack_info) - - upstack_push_parser = upstack_subparsers.add_parser("push", help="Push") - upstack_push_parser.add_argument("--force", "-f", action="store_true", help="Bypass confirmation") - upstack_push_parser.add_argument("--no-pr", dest="pr", action="store_false", help="Skip Create PRs") - upstack_push_parser.set_defaults(func=cmd_upstack_push) - - upstack_sync_parser = upstack_subparsers.add_parser("sync", help="Sync") - upstack_sync_parser.set_defaults(func=cmd_upstack_sync) - - upstack_onto_parser = upstack_subparsers.add_parser("onto", aliases=["restack"], help="Restack") - 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").completer = branch_name_completer - upstack_as_parser.set_defaults(func=cmd_upstack_as) - - # downstack - downstack_parser = subparsers.add_parser( - "downstack", aliases=["ds"], help="Operations on the current downstack" - ) - downstack_subparsers = downstack_parser.add_subparsers(required=True, dest="downstack_command") - - downstack_info_parser = downstack_subparsers.add_parser( - "info", aliases=["i"], help="Info for current downstack" - ) - downstack_info_parser.add_argument("--pr", action="store_true", help="Get PR info (slow)") - downstack_info_parser.set_defaults(func=cmd_downstack_info) - - downstack_push_parser = downstack_subparsers.add_parser("push", help="Push") - downstack_push_parser.add_argument("--force", "-f", action="store_true", help="Bypass confirmation") - downstack_push_parser.add_argument("--no-pr", dest="pr", action="store_false", help="Skip Create PRs") - downstack_push_parser.set_defaults(func=cmd_downstack_push) - - downstack_sync_parser = downstack_subparsers.add_parser("sync", help="Sync") - downstack_sync_parser.set_defaults(func=cmd_downstack_sync) - - # update - 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) - - # 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").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").completer = branch_name_completer - adopt_parser.set_defaults(func=cmd_adopt) - - # land - land_parser = subparsers.add_parser("land", help="Land bottom-most PR on current stack") - land_parser.add_argument("--force", "-f", action="store_true", help="Bypass confirmation") - land_parser.add_argument( - "--auto", - "-a", - action="store_true", - help="Automatically merge after all checks pass", - ) - land_parser.set_defaults(func=cmd_land) - - # shortcuts - push_parser = subparsers.add_parser("push", help="Alias for downstack push") - push_parser.add_argument("--force", "-f", action="store_true", help="Bypass confirmation") - push_parser.add_argument("--no-pr", dest="pr", action="store_false", help="Skip Create PRs") - push_parser.set_defaults(func=cmd_downstack_push) - - sync_parser = subparsers.add_parser("sync", help="Alias for stack sync") - 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="?").completer = branch_name_completer - checkout_parser.set_defaults(func=cmd_branch_checkout) - - checkout_parser = subparsers.add_parser("sco", help="Checkout a branch in this stack") - checkout_parser.set_defaults(func=cmd_stack_checkout) - - # inbox - inbox_parser = subparsers.add_parser("inbox", help="List all active GitHub pull requests for the current user") - inbox_parser.add_argument("--compact", "-c", action="store_true", help="Show compact view") - inbox_parser.set_defaults(func=cmd_inbox) - - # prs - prs_parser = subparsers.add_parser("prs", help="Interactive PR management - select and edit PR descriptions") - prs_parser.set_defaults(func=cmd_prs) - - # fold - fold_parser = subparsers.add_parser("fold", help="Fold current branch into parent branch and delete current branch") - fold_parser.add_argument("--allow-empty", action="store_true", help="Allow empty commits during cherry-pick") - fold_parser.set_defaults(func=cmd_fold) - - argcomplete.autocomplete(parser) - args = parser.parse_args() - logging.basicConfig(format=_LOGGING_FORMAT, level=LOGLEVELS[args.log_level], force=True) - - global COLOR_STDERR - global COLOR_STDOUT - if args.color == "always": - COLOR_STDERR = True - COLOR_STDOUT = True - elif args.color == "never": - COLOR_STDERR = False - COLOR_STDOUT = False - - init_git() - - stack = StackBranchSet() - load_all_stacks(stack) - - global CURRENT_BRANCH - if args.command == "continue": - try: - with open(STATE_FILE) as f: - state = json.load(f) - except FileNotFoundError as e: # noqa: F841 - die("No previous command in progress") - branch = state["branch"] - run(["git", "checkout", branch]) - CURRENT_BRANCH = branch - if CURRENT_BRANCH not in stack.stack: - die("Current branch {} is not in a stack", CURRENT_BRANCH) - - 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` - if CURRENT_BRANCH not in stack.stack: - main_branch = get_real_stack_bottom() - - if get_config().change_to_main and main_branch is not None: - run(["git", "checkout", main_branch]) - CURRENT_BRANCH = main_branch - else: - die("Current branch {} is not in a stack", CURRENT_BRANCH) - - get_current_stack_as_forest(stack) - args.func(stack, args) - - # Success, delete the state file - try: - os.remove(STATE_FILE) - except FileNotFoundError: - pass - except ExitException as e: - error("{}", e.args[0]) - sys.exit(1) - - -def cmd_fold(stack: StackBranchSet, args): - """Fold current branch into parent branch and delete current branch""" - global CURRENT_BRANCH - - if CURRENT_BRANCH not in stack.stack: - die("Current branch {} is not in a stack", CURRENT_BRANCH) - - b = stack.stack[CURRENT_BRANCH] - - if not b.parent: - die("Cannot fold stack bottom branch {}", CURRENT_BRANCH) - - if b.parent.name in STACK_BOTTOMS: - die("Cannot fold into stack bottom branch {}", b.parent.name) - - if not b.is_synced_with_parent(): - die( - "Branch {} is not synced with parent {}, sync before folding", - b.name, - b.parent.name, - ) - - # Get commits to be applied - commits_to_apply = get_commits_between(b.parent_commit, b.commit) - if not commits_to_apply: - info("No commits to fold from {} into {}", b.name, b.parent.name) - else: - cout("Folding {} commits from {} into {}\n", len(commits_to_apply), b.name, b.parent.name, fg="green") - - # Get children that need to be reparented - children = list(b.children) - if children: - cout("Reparenting {} children to {}\n", len(children), b.parent.name, fg="yellow") - for child in children: - cout(" {} -> {}\n", child.name, b.parent.name, fg="gray") - - # Switch to parent branch - checkout(b.parent.name) - CURRENT_BRANCH = b.parent.name - - # Choose between merge and cherry-pick based on config - if get_config().use_merge: - # Merge approach: merge the child branch into parent - inner_do_merge_fold(stack, b.name, b.parent.name, [child.name for child in children]) - else: - # Cherry-pick approach: apply individual commits - if commits_to_apply: - # Reverse the list since get_commits_between returns newest first - commits_to_apply = list(reversed(commits_to_apply)) - # Use inner_do_fold for state management - inner_do_fold(stack, b.name, b.parent.name, commits_to_apply, [child.name for child in children], args.allow_empty) - else: - # No commits to apply, just finish the fold operation - finish_fold_operation(stack, b.name, b.parent.name, [child.name for child in children]) - - return # Early return since both paths handle completion - - -def inner_do_merge_fold(stack: StackBranchSet, fold_branch_name: BranchName, parent_branch_name: BranchName, - children_names: List[BranchName]): - """Perform merge-based fold operation with state management""" - print() - - # Save state for potential continuation - with open(TMP_STATE_FILE, "w") as f: - json.dump({ - "branch": CURRENT_BRANCH, - "merge_fold": { - "fold_branch": fold_branch_name, - "parent_branch": parent_branch_name, - "children": children_names, - } - }, f) - os.replace(TMP_STATE_FILE, STATE_FILE) # make the write atomic - - cout("Merging {} into {}\n", fold_branch_name, parent_branch_name, fg="green") - result = run(CmdArgs(["git", "merge", fold_branch_name]), check=False) - if result is None: - die("Merge failed for branch {}. Please resolve conflicts and run `stacky continue`", fold_branch_name) - - # Merge successful, complete the fold operation - finish_merge_fold_operation(stack, fold_branch_name, parent_branch_name, children_names) - - -def finish_merge_fold_operation(stack: StackBranchSet, fold_branch_name: BranchName, - parent_branch_name: BranchName, children_names: List[BranchName]): - """Complete the merge-based fold operation after merge is successful""" - global CURRENT_BRANCH - - # Get the updated branches from the stack - fold_branch = stack.stack.get(fold_branch_name) - parent_branch = stack.stack[parent_branch_name] - - if not fold_branch: - # Branch might have been deleted already, just finish up - cout("✓ Merge fold operation completed\n", fg="green") - return - - # Update parent branch commit in stack - parent_branch.commit = get_commit(parent_branch_name) - - # Reparent children - for child_name in children_names: - if child_name in stack.stack: - child = stack.stack[child_name] - info("Reparenting {} from {} to {}", child.name, fold_branch.name, parent_branch.name) - child.parent = parent_branch - parent_branch.children.add(child) - fold_branch.children.discard(child) - set_parent(child.name, parent_branch.name) - # Update the child's parent commit to the new parent's tip - set_parent_commit(child.name, parent_branch.commit, child.parent_commit) - child.parent_commit = parent_branch.commit - - # Remove the folded branch from its parent's children - parent_branch.children.discard(fold_branch) - - # Delete the branch - info("Deleting branch {}", fold_branch.name) - run(CmdArgs(["git", "branch", "-D", fold_branch.name])) - - # Clean up stack parent ref - run(CmdArgs(["git", "update-ref", "-d", "refs/stack-parent/{}".format(fold_branch.name)])) - - # Remove from stack - stack.remove(fold_branch.name) - - cout("✓ Successfully merged and folded {} into {}\n", fold_branch.name, parent_branch.name, fg="green") - - -def inner_do_fold(stack: StackBranchSet, fold_branch_name: BranchName, parent_branch_name: BranchName, - commits_to_apply: List[str], children_names: List[BranchName], allow_empty: bool): - """Continue folding operation from saved state""" - print() - - # If no commits to apply, skip cherry-picking and go straight to cleanup - if not commits_to_apply: - finish_fold_operation(stack, fold_branch_name, parent_branch_name, children_names) - return - - while commits_to_apply: - with open(TMP_STATE_FILE, "w") as f: - json.dump({ - "branch": CURRENT_BRANCH, - "fold": { - "fold_branch": fold_branch_name, - "parent_branch": parent_branch_name, - "commits": commits_to_apply, - "children": children_names, - "allow_empty": allow_empty - } - }, f) - os.replace(TMP_STATE_FILE, STATE_FILE) # make the write atomic - - commit = commits_to_apply.pop() - - # Check if this commit would be empty by doing a dry-run cherry-pick - dry_run_result = run(CmdArgs(["git", "cherry-pick", "--no-commit", commit]), check=False) - if dry_run_result is not None: - # Check if there are any changes staged - has_changes = run(CmdArgs(["git", "diff", "--cached", "--quiet"]), check=False) is None - - # Reset the working directory and index since we only wanted to test - run(CmdArgs(["git", "reset", "--hard", "HEAD"])) - - if not has_changes: - cout("Skipping empty commit {}\n", commit[:8], fg="yellow") - continue - else: - # Cherry-pick failed during dry run, reset and try normal cherry-pick - # This could happen due to conflicts, so we'll let the normal cherry-pick handle it - run(CmdArgs(["git", "reset", "--hard", "HEAD"]), check=False) - - cout("Cherry-picking commit {}\n", commit[:8], fg="green") - cherry_pick_cmd = ["git", "cherry-pick"] - if allow_empty: - cherry_pick_cmd.append("--allow-empty") - cherry_pick_cmd.append(commit) - result = run(CmdArgs(cherry_pick_cmd), check=False) - if result is None: - die("Cherry-pick failed for commit {}. Please resolve conflicts and run `stacky continue`", commit) - - # All commits applied successfully, now finish the fold operation - finish_fold_operation(stack, fold_branch_name, parent_branch_name, children_names) - - -def finish_fold_operation(stack: StackBranchSet, fold_branch_name: BranchName, - parent_branch_name: BranchName, children_names: List[BranchName]): - """Complete the fold operation after all commits are applied""" - global CURRENT_BRANCH - - # Get the updated branches from the stack - fold_branch = stack.stack.get(fold_branch_name) - parent_branch = stack.stack[parent_branch_name] - - if not fold_branch: - # Branch might have been deleted already, just finish up - cout("✓ Fold operation completed\n", fg="green") - return - - # Update parent branch commit in stack - parent_branch.commit = get_commit(parent_branch_name) - - # Reparent children - for child_name in children_names: - if child_name in stack.stack: - child = stack.stack[child_name] - info("Reparenting {} from {} to {}", child.name, fold_branch.name, parent_branch.name) - child.parent = parent_branch - parent_branch.children.add(child) - fold_branch.children.discard(child) - set_parent(child.name, parent_branch.name) - # Update the child's parent commit to the new parent's tip - set_parent_commit(child.name, parent_branch.commit, child.parent_commit) - child.parent_commit = parent_branch.commit - - # Remove the folded branch from its parent's children - parent_branch.children.discard(fold_branch) - - # Delete the branch - info("Deleting branch {}", fold_branch.name) - run(CmdArgs(["git", "branch", "-D", fold_branch.name])) - - # Clean up stack parent ref - run(CmdArgs(["git", "update-ref", "-d", "refs/stack-parent/{}".format(fold_branch.name)])) - - # Remove from stack - stack.remove(fold_branch.name) - - cout("✓ Successfully folded {} into {}\n", fold_branch.name, parent_branch.name, fg="green") - - -if __name__ == "__main__": - main() diff --git a/src/stacky/stacky_test.py b/src/stacky/stacky_test.py deleted file mode 100755 index 24e1b98..0000000 --- a/src/stacky/stacky_test.py +++ /dev/null @@ -1,180 +0,0 @@ -#!/usr/bin/env python3 -import os -import shlex -import subprocess -import unittest -from unittest import mock -from unittest.mock import MagicMock, patch - -from stacky import ( - PRInfos, - _check_returncode, - cmd_land, - find_issue_marker, - get_top_level_dir, - read_config, - stop_muxed_ssh, -) - - -class TestCheckReturnCode(unittest.TestCase): - @patch("stacky.die") - def test_check_returncode_zero(self, mock_die): - sp = subprocess.CompletedProcess(args=["ls"], returncode=0) - _check_returncode(sp, ["ls"]) - mock_die.assert_not_called() - - @patch("stacky.die") - def test_check_returncode_negative(self, mock_die): - sp = subprocess.CompletedProcess(args=["ls"], returncode=-1, stderr=b"error") - _check_returncode(sp, ["ls"]) - mock_die.assert_called_once_with("Killed by signal {}: {}. Stderr was:\n{}", 1, shlex.join(["ls"]), "error") - - @patch("stacky.die") - def test_check_returncode_positive(self, mock_die): - sp = subprocess.CompletedProcess(args=["ls"], returncode=1, stderr=b"error") - _check_returncode(sp, ["ls"]) - mock_die.assert_called_once_with("Exited with status {}: {}. Stderr was:\n{}", 1, shlex.join(["ls"]), "error") - - -class TestStringMethods(unittest.TestCase): - def test_find_issue_marker(self): - out = find_issue_marker("SRE-12") - self.assertTrue(out is not None) - self.assertEqual("SRE-12", out) - - out = find_issue_marker("SRE-12-find-things") - self.assertTrue(out is not None) - self.assertEqual("SRE-12", out) - - out = find_issue_marker("SRE_12") - self.assertTrue(out is not None) - self.assertEqual("SRE-12", out) - - out = find_issue_marker("SRE_12-find-things") - self.assertTrue(out is not None) - self.assertEqual("SRE-12", out) - - out = find_issue_marker("john_SRE_12") - self.assertTrue(out is not None) - self.assertEqual("SRE-12", out) - - out = find_issue_marker("john_SRE_12-find-things") - self.assertTrue(out is not None) - self.assertEqual("SRE-12", out) - - out = find_issue_marker("john_SRE12-find-things") - self.assertTrue(out is not None) - self.assertEqual("SRE-12", out) - - out = find_issue_marker("anna_01_01_SRE-12") - self.assertTrue(out is not None) - self.assertEqual("SRE-12", out) - - out = find_issue_marker("anna_01_01_SRE12") - self.assertTrue(out is not None) - self.assertEqual("SRE-12", out) - - out = find_issue_marker("john_test_12") - self.assertTrue(out is None) - - out = find_issue_marker("john_test12") - self.assertTrue(out is None) - - -class TestCmdLand(unittest.TestCase): - @patch("stacky.COLOR_STDOUT", True) - @patch("sys.stdout.write") - @patch("stacky.get_current_downstack_as_forest") - @patch("stacky.die") - @patch("stacky.cout") - @patch("stacky.confirm") - @patch("stacky.run") - @patch("stacky.CmdArgs") - @patch("stacky.Commit") - def test_cmd_land( - self, - mock_Commit, - mock_CmdArgs, - mock_run, - mock_confirm, - mock_cout, - mock_die, - mock_get_current_downstack_as_forest, - mock_write, - ): - # Mock the args - args = MagicMock() - args.force = False - args.auto = False - - bottom_branch = MagicMock() - bottom_branch.name = "bottom_branch" - - # Mock the stack - stack = MagicMock() - stack.bottoms = [bottom_branch] - - # Mock the branch - branch = MagicMock() - branch.is_synced_with_parent.return_value = True - branch.is_synced_with_remote.return_value = True - branch.load_pr_info.return_value = None - branch.open_pr_info = {"mergeable": "MERGEABLE", "number": 1, "url": "http://example.com"} - branch.name = "branch_name" - branch.parent.name = "parent_name" - - # Mock the forest and branches - mock_get_current_downstack_as_forest.return_value = [ - {"bottom_branch": (bottom_branch, {"branch": (branch, None)})} - ] - - # Mock the CmdArgs - mock_CmdArgs.return_value = ["cmd_args"] - - # Mock the Commit - mock_Commit.return_value = "commit" - - # Call the function - cmd_land(stack, args) - - # Assert the mocks were called correctly - mock_get_current_downstack_as_forest.assert_called_once_with(stack) - branch.is_synced_with_parent.assert_called_once() - branch.is_synced_with_remote.assert_called_once() - branch.load_pr_info.assert_called_once() - mock_write.assert_called_with( - "- Will land PR #1 (\x1b[34mhttp://example.com\x1b[0m) for branch branch_name into branch parent_name\n" - ) - mock_run.assert_called_with(["cmd_args"], out=True) - mock_cout.assert_called_with("\n✓ Success! Run `stacky update` to update local state.\n", fg="green") - - -class TestStopMuxedSsh(unittest.TestCase): - @patch("stacky.get_config", return_value=MagicMock(share_ssh_session=True)) - @patch("stacky.get_remote_type", return_value="host") - @patch("stacky.gen_ssh_mux_cmd", return_value=["ssh", "-S"]) - @patch("subprocess.Popen") - def test_stop_muxed_ssh(self, mock_popen, mock_gen_ssh_mux_cmd, mock_get_remote_type, mock_get_config): - stop_muxed_ssh() - mock_popen.assert_called_once_with(["ssh", "-S", "-O", "exit", "host"], stderr=subprocess.DEVNULL) - - @patch("stacky.get_config", return_value=MagicMock(share_ssh_session=False)) - @patch("stacky.get_remote_type", return_value="host") - @patch("stacky.gen_ssh_mux_cmd", return_value=["ssh", "-S"]) - @patch("subprocess.Popen") - def test_stop_muxed_ssh_no_share(self, mock_popen, mock_gen_ssh_mux_cmd, mock_get_remote_type, mock_get_config): - stop_muxed_ssh() - mock_popen.assert_not_called() - - @patch("stacky.get_config", return_value=MagicMock(share_ssh_session=True)) - @patch("stacky.get_remote_type", return_value=None) - @patch("stacky.gen_ssh_mux_cmd", return_value=["ssh", "-S"]) - @patch("subprocess.Popen") - def test_stop_muxed_ssh_no_host(self, mock_popen, mock_gen_ssh_mux_cmd, mock_get_remote_type, mock_get_config): - stop_muxed_ssh() - mock_popen.assert_not_called() - - -if __name__ == "__main__": - unittest.main() diff --git a/src/stacky/tests/__init__.py b/src/stacky/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/stacky/tests/__pycache__/test_integration.cpython-311.pyc b/src/stacky/tests/__pycache__/test_integration.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..27dd1a553e44a25a23667b4cf8e367100e4c44bd GIT binary patch literal 10187 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 COj{oS literal 0 HcmV?d00001 diff --git a/src/stacky/tests/test_commands/__init__.py b/src/stacky/tests/test_commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/stacky/tests/test_commands/__pycache__/__init__.cpython-311.pyc b/src/stacky/tests/test_commands/__pycache__/__init__.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..3793a9d87fcd0ff1f98dd1730832fdeb42f031a7 GIT binary patch literal 177 zcmZ3^%ge<81c66lGePuY5CH>>P{wCAAY(d13PUi1CZpduVRSzF8xBt9@RGBSQ(fDuK^KrsMNz${k) literal 0 HcmV?d00001 diff --git a/src/stacky/tests/test_commands/__pycache__/test_land.cpython-311.pyc b/src/stacky/tests/test_commands/__pycache__/test_land.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..5a4b0db6fb252c0b1f6b19980cfcdefef71dda4f GIT binary patch literal 7421 zcmd^^Uu+Y}8Ng@l-Szk%Ngx3dNa8?9FbNL-xzKCxAcVUI2qy>+7q#6k-c4e#*Xi!M z0iTi5iU(KXfs;-;t~ymb(L=oO*!Q&Wc4R5mRwqTOc(^ws?j&?i{k~c6+D`0T0@ps! zwf*hPH{Z;BJ3IUPX4ZcThXVwTPyYFv#N=*5{*4*u5UPn+e}cr9L?JUo;S^7jo8jQ> zO?pz^8E=Z8;W^avNg*Z9h#c{dkBB1NCyJulgTlKYLTYXWb?K+9JTS1)H6Dj2~Ov~*4+9Y?TYGgK}#mz)oi)9Cz zP5juz)yr3}N!O<@UcE6roozSn8ZpCE(it|wj$k9fQawI1_4%IAbsoXlsfo0wJJPy|^ zYpF)ku^R5kZMlX!rdzDx*_xdqz_T9A_Tu9mF*5PEY8bPbWQ0YoY8;DQviOvo&@A7q zDw`Qyot}c|HF@o$$@3R3Pkx6pH@kD*G#Ac|jj4C!)B;31s2cTJ{*1rx%s#YkOmKAA1mlE1++6tu?RL#wac0)SOZEKHg z7^)83k&{X2C=K1Q-mIM)3!)*5H`L^;MN<&FC3xgkN;@e7^6CeR;9GyFV{J2$oO1mlsQPutbm7Cr8FmyRSqKuXPWWcke0h8m{>G z{y>F*U`0O!NcREOoA`kB2Pf!pZRAwJt{2|*F!ea`eCY3k&-)68J}h*dFN7{syq>{8 zK3JlWibw{cd0L{qFKGM2u19>4_ClsWd)Mf$RXValN4}XX(i0_mqCiiS=?-vmm7ZOp zXNz>aM8^wsytQ)QDvhnsSdktp(PIUAtV}ys>F^32F4Aa;MhnjO6$~ngyWoZlBA5jh z4+|`8@k>(NJy`I1NREdkA>4x6@(-X_3nnghHUuF7fG*Sph}M>(A^>*XkYKj9^yNed zJH9&Twzj2;2tm+o6}+LfWxC|TuXS%w+YKw=A-(11>RJ_Z-dbCD9oVbls|y&OT6vB< zc7qPy1?R}ETVS6i9BBwAzMQZ=oIvn{&w8yEJV=fcgJ1OA@qS7cIgWe^Z9#zGng^ft zfkg2EylHk6A5ELsvvMM7L>A{&Euy6X=nR|@1ZTwNT};Q>@RYN{lXnv4fJ?aS z?y08cmvkIP9mioR8n!yC3!{t)-NcgVxw(Wkr^lew3K|JTmDJf;HEuGh>m68g7m7|4 zT_|8TNAw;LR>&snH929ZhE3sc18krA^oiL)y-j0cgcR9jO z4EmObIgrg^*YhsJ&et4v41zU`Z4H7hvKR#2+)qM~wuE31ZqtUNDFnM~X1Qh%q_6|n zu+rWHf?HZmD-BOC zjwy_4`Uw;#L2M6Hwl`Y@m2G?rJGuTp2UXoF?OkV9TgSwxcX^bbIObbE$$@OntPlSm znT0HC!YqY?zD6ULWvG{2!fd*{bNAznY;Nu?$kvoEP05BUk?QfYkgd&Wt^T}Wd1sR? z(qefR!ToIHlWX-f3rwXQbtA#r-+*9y!8sk7zAcvRUBBp(s{8Kj;AX2W$Lb;UXQ-Rg z>ziK}`_Gm7&;5L>tU7qrlC>>nx$gkmQof^Wumc?vA9$BP;4gZ8{}4HlQ&GP~CHCG` zlB|Fv+5cfcIwVQI&&Wx9M*}&ZBq`~*B} z@h^6dm*8yye#-n%fgf5E0!udv!Ps-YD4Z?{r^|M}wU literal 0 HcmV?d00001 diff --git a/src/stacky/tests/test_commands/test_land.py b/src/stacky/tests/test_commands/test_land.py new file mode 100644 index 0000000..b2e0b45 --- /dev/null +++ b/src/stacky/tests/test_commands/test_land.py @@ -0,0 +1,186 @@ +#!/usr/bin/env python3 +"""Tests for stacky.commands.land module.""" + +import unittest +from unittest.mock import patch, MagicMock + +from stacky.commands.land import cmd_land + + +class TestCmdLand(unittest.TestCase): + """Tests for cmd_land function.""" + + @patch("stacky.commands.land.COLOR_STDOUT", True) + @patch("sys.stdout.write") + @patch("stacky.commands.land.get_current_branch_name") + @patch("stacky.commands.land.get_current_downstack_as_forest") + @patch("stacky.commands.land.die") + @patch("stacky.commands.land.cout") + @patch("stacky.commands.land.confirm") + @patch("stacky.commands.land.run") + def test_cmd_land_success( + self, + mock_run, + mock_confirm, + mock_cout, + mock_die, + mock_forest, + mock_current_branch, + mock_write, + ): + """Test successful land command.""" + args = MagicMock() + args.force = False + args.auto = False + + bottom_branch = MagicMock() + bottom_branch.name = "main" + + stack = MagicMock() + stack.bottoms = {bottom_branch} + + branch = MagicMock() + branch.name = "feature" + branch.is_synced_with_parent.return_value = True + branch.is_synced_with_remote.return_value = True + branch.load_pr_info.return_value = None + branch.open_pr_info = { + "mergeable": "MERGEABLE", + "number": 1, + "url": "http://example.com" + } + branch.parent = MagicMock() + branch.parent.name = "main" + + mock_current_branch.return_value = "feature" + mock_forest.return_value = [ + {"main": (bottom_branch, {"feature": (branch, None)})} + ] + mock_run.return_value = "abc123" + + cmd_land(stack, args) + + mock_forest.assert_called_once_with(stack) + branch.is_synced_with_parent.assert_called_once() + branch.is_synced_with_remote.assert_called_once() + branch.load_pr_info.assert_called_once() + mock_confirm.assert_called_once() + + @patch("stacky.commands.land.get_current_branch_name") + @patch("stacky.commands.land.get_current_downstack_as_forest") + @patch("stacky.commands.land.die") + def test_cmd_land_not_synced_parent( + self, + mock_die, + mock_forest, + mock_current_branch, + ): + """Test land fails when not synced with parent.""" + from stacky.utils.logging import ExitException + mock_die.side_effect = ExitException("Not synced with parent") + + args = MagicMock() + args.force = False + + bottom_branch = MagicMock() + bottom_branch.name = "main" + + stack = MagicMock() + stack.bottoms = {bottom_branch} + + branch = MagicMock() + branch.name = "feature" + branch.is_synced_with_parent.return_value = False + branch.parent = MagicMock() + branch.parent.name = "main" + + mock_current_branch.return_value = "feature" + mock_forest.return_value = [ + {"main": (bottom_branch, {"feature": (branch, None)})} + ] + + with self.assertRaises(ExitException): + cmd_land(stack, args) + mock_die.assert_called() + + @patch("stacky.commands.land.get_current_branch_name") + @patch("stacky.commands.land.get_current_downstack_as_forest") + @patch("stacky.commands.land.die") + def test_cmd_land_not_synced_remote( + self, + mock_die, + mock_forest, + mock_current_branch, + ): + """Test land fails when not synced with remote.""" + from stacky.utils.logging import ExitException + mock_die.side_effect = ExitException("Not synced with remote") + + args = MagicMock() + args.force = False + + bottom_branch = MagicMock() + bottom_branch.name = "main" + + stack = MagicMock() + stack.bottoms = {bottom_branch} + + branch = MagicMock() + branch.name = "feature" + branch.is_synced_with_parent.return_value = True + branch.is_synced_with_remote.return_value = False + branch.parent = MagicMock() + branch.parent.name = "main" + + mock_current_branch.return_value = "feature" + mock_forest.return_value = [ + {"main": (bottom_branch, {"feature": (branch, None)})} + ] + + with self.assertRaises(ExitException): + cmd_land(stack, args) + mock_die.assert_called() + + @patch("stacky.commands.land.get_current_branch_name") + @patch("stacky.commands.land.get_current_downstack_as_forest") + @patch("stacky.commands.land.die") + def test_cmd_land_no_open_pr( + self, + mock_die, + mock_forest, + mock_current_branch, + ): + """Test land fails when no open PR.""" + from stacky.utils.logging import ExitException + mock_die.side_effect = ExitException("No open PR") + + args = MagicMock() + args.force = False + + bottom_branch = MagicMock() + bottom_branch.name = "main" + + stack = MagicMock() + stack.bottoms = {bottom_branch} + + branch = MagicMock() + branch.name = "feature" + branch.is_synced_with_parent.return_value = True + branch.is_synced_with_remote.return_value = True + branch.load_pr_info.return_value = None + branch.open_pr_info = None + branch.parent = MagicMock() + branch.parent.name = "main" + + mock_current_branch.return_value = "feature" + mock_forest.return_value = [ + {"main": (bottom_branch, {"feature": (branch, None)})} + ] + + with self.assertRaises(ExitException): + cmd_land(stack, args) + mock_die.assert_called() + + +if __name__ == "__main__": + unittest.main() diff --git a/src/stacky/tests/test_git/__init__.py b/src/stacky/tests/test_git/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/stacky/tests/test_git/__pycache__/__init__.cpython-311.pyc b/src/stacky/tests/test_git/__pycache__/__init__.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..6ebd72aacf459659f64215ddb8031b43de9ac098 GIT binary patch literal 172 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 literal 0 HcmV?d00001 diff --git a/src/stacky/tests/test_git/__pycache__/test_refs.cpython-311.pyc b/src/stacky/tests/test_git/__pycache__/test_refs.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..8848208be77793318bf2a4df319078aa3cc5cdf9 GIT binary patch literal 9584 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 J3Obk}{1-9on;ZZD literal 0 HcmV?d00001 diff --git a/src/stacky/tests/test_git/test_branch.py b/src/stacky/tests/test_git/test_branch.py new file mode 100644 index 0000000..69cbdd4 --- /dev/null +++ b/src/stacky/tests/test_git/test_branch.py @@ -0,0 +1,122 @@ +#!/usr/bin/env python3 +"""Tests for stacky.git.branch module.""" + +import unittest +from unittest.mock import patch, MagicMock + +from stacky.git.branch import ( + get_current_branch, get_all_branches, get_stack_parent_branch, + get_real_stack_bottom, checkout, create_branch +) +from stacky.utils.types import BranchName + + +class TestGetCurrentBranch(unittest.TestCase): + """Tests for get_current_branch function.""" + + @patch("stacky.git.branch.run") + def test_get_current_branch_success(self, mock_run): + """Test get_current_branch returns branch name.""" + mock_run.return_value = "refs/heads/feature-branch" + result = get_current_branch() + self.assertEqual(result, "feature-branch") + + @patch("stacky.git.branch.run") + def test_get_current_branch_detached_head(self, mock_run): + """Test get_current_branch returns None on detached HEAD.""" + mock_run.return_value = None + result = get_current_branch() + self.assertIsNone(result) + + +class TestGetAllBranches(unittest.TestCase): + """Tests for get_all_branches function.""" + + @patch("stacky.git.branch.run_multiline") + def test_get_all_branches(self, mock_run_multiline): + """Test get_all_branches returns list of branch names.""" + mock_run_multiline.return_value = "main\nfeature-1\nfeature-2\n" + result = get_all_branches() + self.assertEqual(result, [BranchName("main"), BranchName("feature-1"), BranchName("feature-2")]) + + @patch("stacky.git.branch.run_multiline") + def test_get_all_branches_empty(self, mock_run_multiline): + """Test get_all_branches with no branches.""" + mock_run_multiline.return_value = "" + result = get_all_branches() + self.assertEqual(result, []) + + +class TestGetStackParentBranch(unittest.TestCase): + """Tests for get_stack_parent_branch function.""" + + @patch("stacky.git.branch.run") + def test_get_stack_parent_branch_success(self, mock_run): + """Test getting parent branch.""" + mock_run.return_value = "refs/heads/parent-branch" + result = get_stack_parent_branch(BranchName("child-branch")) + self.assertEqual(result, "parent-branch") + + @patch("stacky.git.branch.run") + def test_get_stack_parent_branch_no_parent(self, mock_run): + """Test getting parent when no parent configured.""" + mock_run.return_value = None + result = get_stack_parent_branch(BranchName("orphan-branch")) + self.assertIsNone(result) + + def test_get_stack_parent_branch_is_bottom(self): + """Test getting parent of stack bottom returns None.""" + result = get_stack_parent_branch(BranchName("master")) + self.assertIsNone(result) + + +class TestGetRealStackBottom(unittest.TestCase): + """Tests for get_real_stack_bottom function.""" + + @patch("stacky.git.branch.get_all_branches") + def test_get_real_stack_bottom_master(self, mock_get_all): + """Test finding master as stack bottom.""" + mock_get_all.return_value = [BranchName("master"), BranchName("feature")] + result = get_real_stack_bottom() + self.assertEqual(result, "master") + + @patch("stacky.git.branch.get_all_branches") + def test_get_real_stack_bottom_main(self, mock_get_all): + """Test finding main as stack bottom.""" + mock_get_all.return_value = [BranchName("main"), BranchName("feature")] + result = get_real_stack_bottom() + self.assertEqual(result, "main") + + @patch("stacky.git.branch.get_all_branches") + def test_get_real_stack_bottom_none(self, mock_get_all): + """Test no stack bottom found.""" + mock_get_all.return_value = [BranchName("feature")] + result = get_real_stack_bottom() + self.assertIsNone(result) + + +class TestCheckout(unittest.TestCase): + """Tests for checkout function.""" + + @patch("stacky.git.branch.run") + @patch("stacky.git.branch.info") + def test_checkout(self, mock_info, mock_run): + """Test checkout calls git checkout.""" + checkout(BranchName("feature")) + mock_run.assert_called_once_with(["git", "checkout", "feature"], out=True) + + +class TestCreateBranch(unittest.TestCase): + """Tests for create_branch function.""" + + @patch("stacky.git.branch.run") + def test_create_branch(self, mock_run): + """Test create_branch calls git checkout -b with track.""" + create_branch(BranchName("new-feature")) + mock_run.assert_called_once_with( + ["git", "checkout", "-b", "new-feature", "--track"], out=True + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/src/stacky/tests/test_git/test_refs.py b/src/stacky/tests/test_git/test_refs.py new file mode 100644 index 0000000..b2a8e3d --- /dev/null +++ b/src/stacky/tests/test_git/test_refs.py @@ -0,0 +1,130 @@ +#!/usr/bin/env python3 +"""Tests for stacky.git.refs module.""" + +import unittest +from unittest.mock import patch + +from stacky.git.refs import ( + get_stack_parent_commit, get_commit, set_parent_commit, + get_branch_name_from_short_ref, get_all_stack_bottoms, + get_commits_between, get_merge_base +) +from stacky.utils.types import BranchName, Commit + + +class TestGetStackParentCommit(unittest.TestCase): + """Tests for get_stack_parent_commit function.""" + + @patch("stacky.git.refs.run") + def test_get_stack_parent_commit_success(self, mock_run): + """Test getting parent commit.""" + mock_run.return_value = "abc123" + result = get_stack_parent_commit(BranchName("feature")) + self.assertEqual(result, Commit("abc123")) + + @patch("stacky.git.refs.run") + def test_get_stack_parent_commit_none(self, mock_run): + """Test getting parent commit when not set.""" + mock_run.return_value = None + result = get_stack_parent_commit(BranchName("feature")) + self.assertIsNone(result) + + +class TestGetCommit(unittest.TestCase): + """Tests for get_commit function.""" + + @patch("stacky.git.refs.run") + def test_get_commit(self, mock_run): + """Test getting branch commit.""" + mock_run.return_value = "def456" + result = get_commit(BranchName("main")) + self.assertEqual(result, Commit("def456")) + + +class TestSetParentCommit(unittest.TestCase): + """Tests for set_parent_commit function.""" + + @patch("stacky.git.refs.run") + def test_set_parent_commit(self, mock_run): + """Test setting parent commit.""" + set_parent_commit(BranchName("feature"), Commit("abc123")) + mock_run.assert_called_once() + call_args = mock_run.call_args[0][0] + self.assertIn("update-ref", call_args) + self.assertIn("refs/stack-parent/feature", call_args) + self.assertIn("abc123", call_args) + + @patch("stacky.git.refs.run") + def test_set_parent_commit_with_prev(self, mock_run): + """Test setting parent commit with previous value.""" + set_parent_commit(BranchName("feature"), Commit("abc123"), "old123") + mock_run.assert_called_once() + call_args = mock_run.call_args[0][0] + self.assertIn("old123", call_args) + + +class TestGetBranchNameFromShortRef(unittest.TestCase): + """Tests for get_branch_name_from_short_ref function.""" + + def test_get_branch_name_from_short_ref(self): + """Test extracting branch name from short ref.""" + result = get_branch_name_from_short_ref("stack-parent/feature") + self.assertEqual(result, BranchName("feature")) + + def test_get_branch_name_from_short_ref_invalid(self): + """Test invalid ref format raises error.""" + from stacky.utils.logging import ExitException + # The function will raise ExitException via die() for invalid refs + with self.assertRaises(ExitException): + get_branch_name_from_short_ref("invalid") + + +class TestGetAllStackBottoms(unittest.TestCase): + """Tests for get_all_stack_bottoms function.""" + + @patch("stacky.git.refs.run_multiline") + def test_get_all_stack_bottoms(self, mock_run): + """Test getting all stack bottom branches.""" + mock_run.return_value = "stacky-bottom-branch/feature-1\nstacky-bottom-branch/feature-2\n" + result = get_all_stack_bottoms() + self.assertEqual(result, [BranchName("feature-1"), BranchName("feature-2")]) + + @patch("stacky.git.refs.run_multiline") + def test_get_all_stack_bottoms_empty(self, mock_run): + """Test no stack bottoms.""" + mock_run.return_value = "" + result = get_all_stack_bottoms() + self.assertEqual(result, []) + + +class TestGetCommitsBetween(unittest.TestCase): + """Tests for get_commits_between function.""" + + @patch("stacky.git.refs.run_multiline") + def test_get_commits_between(self, mock_run): + """Test getting commits between two refs.""" + mock_run.return_value = "abc123\ndef456\n" + result = get_commits_between(Commit("start"), Commit("end")) + self.assertEqual(result, ["abc123", "def456"]) + + @patch("stacky.git.refs.run_multiline") + def test_get_commits_between_empty(self, mock_run): + """Test no commits between refs.""" + mock_run.return_value = "" + result = get_commits_between(Commit("same"), Commit("same")) + self.assertEqual(result, []) + + +class TestGetMergeBase(unittest.TestCase): + """Tests for get_merge_base function.""" + + @patch("stacky.git.refs.run") + def test_get_merge_base(self, mock_run): + """Test getting merge base.""" + mock_run.return_value = "abc123" + result = get_merge_base(BranchName("main"), BranchName("feature")) + self.assertEqual(result, "abc123") + + +if __name__ == "__main__": + unittest.main() diff --git a/src/stacky/tests/test_git/test_remote.py b/src/stacky/tests/test_git/test_remote.py new file mode 100644 index 0000000..bc24c3a --- /dev/null +++ b/src/stacky/tests/test_git/test_remote.py @@ -0,0 +1,94 @@ +#!/usr/bin/env python3 +"""Tests for stacky.git.remote module.""" + +import subprocess +import unittest +from unittest.mock import patch, MagicMock + +from stacky.git.remote import ( + get_remote_type, gen_ssh_mux_cmd, stop_muxed_ssh, start_muxed_ssh +) + + +class TestGetRemoteType(unittest.TestCase): + """Tests for get_remote_type function.""" + + @patch("stacky.git.remote.run_always_return") + def test_get_remote_type_ssh(self, mock_run): + """Test getting SSH remote type.""" + mock_run.return_value = "origin\tgit@github.com:user/repo.git (push)" + result = get_remote_type("origin") + self.assertEqual(result, "git@github.com") + + @patch("stacky.git.remote.run_always_return") + def test_get_remote_type_https(self, mock_run): + """Test getting HTTPS remote type returns None.""" + mock_run.return_value = "origin\thttps://github.com/user/repo.git (push)" + result = get_remote_type("origin") + self.assertIsNone(result) + + +class TestGenSshMuxCmd(unittest.TestCase): + """Tests for gen_ssh_mux_cmd function.""" + + def test_gen_ssh_mux_cmd(self): + """Test SSH mux command generation.""" + cmd = gen_ssh_mux_cmd() + self.assertEqual(cmd[0], "ssh") + self.assertIn("-o", cmd) + self.assertIn("ControlMaster=auto", cmd) + self.assertIn("ControlPath=~/.ssh/stacky-%C", cmd) + + +class TestStopMuxedSsh(unittest.TestCase): + """Tests for stop_muxed_ssh function.""" + + @patch("stacky.git.remote.get_config") + @patch("stacky.git.remote.get_remote_type") + @patch("stacky.git.remote.gen_ssh_mux_cmd") + @patch("subprocess.Popen") + def test_stop_muxed_ssh(self, mock_popen, mock_gen_cmd, mock_get_remote, mock_get_config): + """Test stopping muxed SSH connection.""" + mock_get_config.return_value = MagicMock(share_ssh_session=True) + mock_get_remote.return_value = "git@github.com" + mock_gen_cmd.return_value = ["ssh", "-S"] + + stop_muxed_ssh() + + mock_popen.assert_called_once_with( + ["ssh", "-S", "-O", "exit", "git@github.com"], + stderr=subprocess.DEVNULL + ) + + @patch("stacky.git.remote.get_config") + @patch("subprocess.Popen") + def test_stop_muxed_ssh_disabled(self, mock_popen, mock_get_config): + """Test stop_muxed_ssh does nothing when disabled.""" + mock_get_config.return_value = MagicMock(share_ssh_session=False) + stop_muxed_ssh() + mock_popen.assert_not_called() + + @patch("stacky.git.remote.get_config") + @patch("stacky.git.remote.get_remote_type") + @patch("subprocess.Popen") + def test_stop_muxed_ssh_no_host(self, mock_popen, mock_get_remote, mock_get_config): + """Test stop_muxed_ssh does nothing when no SSH host.""" + mock_get_config.return_value = MagicMock(share_ssh_session=True) + mock_get_remote.return_value = None + stop_muxed_ssh() + mock_popen.assert_not_called() + + +class TestStartMuxedSsh(unittest.TestCase): + """Tests for start_muxed_ssh function.""" + + @patch("stacky.git.remote.get_config") + def test_start_muxed_ssh_disabled(self, mock_get_config): + """Test start_muxed_ssh does nothing when disabled.""" + mock_get_config.return_value = MagicMock(share_ssh_session=False) + # Should not raise any errors + start_muxed_ssh() + + +if __name__ == "__main__": + unittest.main() diff --git a/src/stacky/tests/test_integration.py b/src/stacky/tests/test_integration.py new file mode 100644 index 0000000..bbec1f3 --- /dev/null +++ b/src/stacky/tests/test_integration.py @@ -0,0 +1,169 @@ +#!/usr/bin/env python3 +"""Integration tests for stacky workflow.""" + +import unittest +from unittest.mock import patch, MagicMock + +from stacky.stack.models import StackBranch, StackBranchSet +from stacky.utils.types import BranchName, Commit + + +class TestStackWorkflow(unittest.TestCase): + """Integration tests for stack workflow.""" + + @patch("stacky.stack.models.get_remote_info") + @patch("stacky.stack.models.get_commit") + def test_build_simple_stack(self, mock_get_commit, mock_get_remote): + """Test building a simple two-branch stack.""" + # Mock git operations + mock_get_commit.return_value = Commit("abc123") + mock_get_remote.return_value = ("origin", BranchName("main"), Commit("abc123")) + + # Create stack + stack = StackBranchSet() + + # Add main branch (bottom) + main = stack.add( + BranchName("main"), + parent=None, + parent_commit=None + ) + + # Add feature branch + mock_get_commit.return_value = Commit("def456") + mock_get_remote.return_value = ("origin", BranchName("feature"), Commit("def456")) + feature = stack.add( + BranchName("feature"), + parent=main, + parent_commit=Commit("abc123") + ) + stack.add_child(main, feature) + + # Verify stack structure + self.assertEqual(len(stack.stack), 2) + self.assertIn(main, stack.bottoms) + self.assertIn(feature, stack.tops) + self.assertIn(feature, main.children) + self.assertEqual(feature.parent, main) + + @patch("stacky.stack.models.get_remote_info") + @patch("stacky.stack.models.get_commit") + def test_build_branching_stack(self, mock_get_commit, mock_get_remote): + """Test building a stack with multiple branches from one parent.""" + mock_get_commit.return_value = Commit("abc123") + mock_get_remote.return_value = ("origin", BranchName("main"), Commit("abc123")) + + stack = StackBranchSet() + + # Add main + main = stack.add(BranchName("main"), parent=None, parent_commit=None) + + # Add feature-1 + mock_get_commit.return_value = Commit("def456") + mock_get_remote.return_value = ("origin", BranchName("feature-1"), Commit("def456")) + feature1 = stack.add(BranchName("feature-1"), parent=main, parent_commit=Commit("abc123")) + stack.add_child(main, feature1) + + # Add feature-2 (also from main) + mock_get_commit.return_value = Commit("ghi789") + mock_get_remote.return_value = ("origin", BranchName("feature-2"), Commit("ghi789")) + feature2 = stack.add(BranchName("feature-2"), parent=main, parent_commit=Commit("abc123")) + stack.add_child(main, feature2) + + # Verify structure + self.assertEqual(len(main.children), 2) + self.assertIn(feature1, main.children) + self.assertIn(feature2, main.children) + self.assertEqual(len(stack.tops), 2) + + @patch("stacky.stack.models.get_remote_info") + @patch("stacky.stack.models.get_commit") + def test_sync_status_detection(self, mock_get_commit, mock_get_remote): + """Test that sync status is correctly detected.""" + mock_get_commit.return_value = Commit("abc123") + mock_get_remote.return_value = ("origin", BranchName("main"), Commit("abc123")) + + stack = StackBranchSet() + main = stack.add(BranchName("main"), parent=None, parent_commit=None) + + # Add feature synced with parent + mock_get_commit.return_value = Commit("def456") + mock_get_remote.return_value = ("origin", BranchName("feature"), Commit("def456")) + feature = stack.add(BranchName("feature"), parent=main, parent_commit=Commit("abc123")) + + # Initially synced + self.assertTrue(feature.is_synced_with_parent()) + self.assertTrue(feature.is_synced_with_remote()) + + # Simulate parent moving ahead + main.commit = Commit("new123") + self.assertFalse(feature.is_synced_with_parent()) + + # Simulate remote moving ahead + feature.remote_commit = Commit("remote456") + self.assertFalse(feature.is_synced_with_remote()) + + +class TestTreeTraversal(unittest.TestCase): + """Integration tests for tree traversal.""" + + @patch("stacky.stack.models.get_remote_info") + @patch("stacky.stack.models.get_commit") + def test_forest_traversal_order(self, mock_get_commit, mock_get_remote): + """Test that forest traversal visits branches in correct order.""" + from stacky.stack.tree import depth_first, forest_depth_first, make_tree + from stacky.utils.types import BranchesTreeForest + + mock_get_commit.return_value = Commit("abc123") + mock_get_remote.return_value = ("origin", BranchName("main"), Commit("abc123")) + + stack = StackBranchSet() + main = stack.add(BranchName("main"), parent=None, parent_commit=None) + + mock_get_commit.return_value = Commit("def456") + mock_get_remote.return_value = ("origin", BranchName("feature"), Commit("def456")) + feature = stack.add(BranchName("feature"), parent=main, parent_commit=Commit("abc123")) + stack.add_child(main, feature) + + tree = make_tree(main) + branches = list(depth_first(tree)) + + self.assertEqual(len(branches), 2) + self.assertEqual(branches[0].name, "main") + self.assertEqual(branches[1].name, "feature") + + +class TestPRInfoLoading(unittest.TestCase): + """Integration tests for PR info loading.""" + + @patch("stacky.stack.models.get_remote_info") + @patch("stacky.stack.models.get_commit") + @patch("stacky.pr.github.get_pr_info") + def test_lazy_pr_loading(self, mock_get_pr_info, mock_get_commit, mock_get_remote): + """Test that PR info is loaded lazily.""" + from stacky.stack.models import PRInfos + + mock_get_commit.return_value = Commit("abc123") + mock_get_remote.return_value = ("origin", BranchName("feature"), Commit("abc123")) + mock_get_pr_info.return_value = PRInfos( + all={"pr1": {"id": "pr1", "state": "OPEN", "number": 1}}, + open={"id": "pr1", "state": "OPEN", "number": 1} + ) + + stack = StackBranchSet() + feature = stack.add(BranchName("feature"), parent=None, parent_commit=None) + + # PR info not loaded yet + self.assertFalse(feature._pr_info_loaded) + self.assertEqual(feature.pr_info, {}) + + # Load PR info + feature.load_pr_info() + + # Now loaded + self.assertTrue(feature._pr_info_loaded) + mock_get_pr_info.assert_called_once_with(BranchName("feature")) + + +if __name__ == "__main__": + unittest.main() diff --git a/src/stacky/tests/test_pr/__init__.py b/src/stacky/tests/test_pr/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/stacky/tests/test_pr/__pycache__/__init__.cpython-311.pyc b/src/stacky/tests/test_pr/__pycache__/__init__.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..94b5cb309c624a3620887fe9dac0aa0cb07fcb40 GIT binary patch literal 171 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 +**Stack:** +- branch1 (#1) +- branch2 (#2) + + +More description""" + result = extract_stack_comment(body) + self.assertIn("Stacky Stack Info", result) + self.assertIn("branch1", result) + + def test_extract_no_comment(self): + """Test extracting when no comment exists.""" + body = "Just a regular PR description" + result = extract_stack_comment(body) + self.assertEqual(result, "") + + def test_extract_empty_body(self): + """Test extracting from empty body.""" + result = extract_stack_comment("") + self.assertEqual(result, "") + + def test_extract_none_body(self): + """Test extracting from None body.""" + result = extract_stack_comment(None) + self.assertEqual(result, "") + + +class TestGenerateStackString(unittest.TestCase): + """Tests for generate_stack_string function.""" + + def test_generate_empty_forest(self): + """Test generating stack string for empty forest.""" + forest = BranchesTreeForest([]) + branch = MagicMock() + branch.name = BranchName("feature") + result = generate_stack_string(forest, branch) + self.assertEqual(result, "") + + def test_generate_with_branches(self): + """Test generating stack string with branches.""" + branch1 = MagicMock() + branch1.name = BranchName("feature-1") + branch1.open_pr_info = {"number": 1} + + branch2 = MagicMock() + branch2.name = BranchName("feature-2") + branch2.open_pr_info = {"number": 2} + + tree = BranchesTree({ + "feature-1": (branch1, BranchesTree({ + "feature-2": (branch2, BranchesTree({})) + })) + }) + forest = BranchesTreeForest([tree]) + + result = generate_stack_string(forest, branch2) + self.assertIn("Stacky Stack Info", result) + self.assertIn("feature-1", result) + self.assertIn("feature-2", result) + self.assertIn("CURRENT PR", result) + + +if __name__ == "__main__": + unittest.main() diff --git a/src/stacky/tests/test_stack/__init__.py b/src/stacky/tests/test_stack/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/stacky/tests/test_stack/__pycache__/__init__.cpython-311.pyc b/src/stacky/tests/test_stack/__pycache__/__init__.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..48099e20319ab70881144397949b6c10bb5cf7f7 GIT binary patch literal 174 zcmZ3^%ge<81c66lGePuY5CH>>P{wCAAY(d13PUi1CZpdC%vKQO?EB4(f%0Idrv7ytkO literal 0 HcmV?d00001 diff --git a/src/stacky/tests/test_stack/__pycache__/test_models.cpython-311.pyc b/src/stacky/tests/test_stack/__pycache__/test_models.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..d46c8667f95e01bfb686d12ab57b1eaae3a87052 GIT binary patch literal 9588 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 literal 0 HcmV?d00001 diff --git a/src/stacky/tests/test_stack/__pycache__/test_tree.cpython-311.pyc b/src/stacky/tests/test_stack/__pycache__/test_tree.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..1b3e1c35065bad513b2464a4f756cabbdd8b7c04 GIT binary patch literal 8868 zcmd5>-EZ606(>bXqHXzuPOTM*vGSau@Eh1(?ij$^xB+c;_Bw3*P3Q$*TUtPl5+ zN@l?sJRk$Sp}@MLL%pHJ{FEk0ANDd}*wY^N2T%ov83Y0Z8~U&}2ku__v~%tiWm1&X zcEQk6(h={yhxcCn&c{9H=%dcgb`Gw}-`>n#-N$kN!iIZ=t2es}&vEZ_8kgcUUJK>; z6c67*E|eEiLS9UXJobyZa6XcXfyN%_X4Ly?r>UE=WodF!gmFZdjKDHrFtos1YB(`xb{)5cHru0!Sxj7iUC(= z3$8vru5~SkaD5_&IN*G`x*6^++&pj_9Q>-#75YhZmxtzc!!(qIB2f%8ompN@(yydR zbY008wNg$`{u>{iON5;8O4`gUIc*oxi`mSDVrDrJb@nalrn*8@c+4yrs-7?2$U0s5 z^s=tv9BQGc=}wHcjnZ}Oa@uO6P6sTUPn&8Xo!6ZXOb!%Y68%( zCIXFUVW3eIa!S&oSGa`aJcU5LpqrP-75YnO@Q0R<@4?pq2W6pD$e7t;A(?TP$32Mf zK~34c1mu0r^o(izU1&Y1_1xyyxUE_X*lX7sX!Tu?_5&($>_%;ctqSFXxG9B7f(o`! zR4!dsvW10Wa?auIhX@*p(~&j|otQIkmC`wot!U`E1t$WSmvW|olPb#RQ{zj;ygt5K zNEZs}m2^QbWXGABj2k55-kPXF<492pGRy21MV(w(b-L+5p@>R?dnga%_Ue&L)^VX- z8Dl`!xliTct@(;PYRjV=k!>mV4{84=(*Di(R&PZbwxwaqeeVK0*BI$*Faq8HFw63{ zc`xE?^@xX>A|BF&0OBGb9xm_4pX5PYBk2VbKugw6%vl?R*S=JL=vo`YxCxkvjBZE9BU&_?tj;P-k( zK55G*{rEjykrK9)u-rE}08ci??+qAX_$_jp@PY6I7>WT5!+>F=++T-bdS!(aZ|WMt zIO|B~E?v5O@wJ(0z^xy_=_2~gtbS`+&twg-Ft>eKf2)M*SVALH{`tW#7~13zH2vuI zP;}i@5a6TD0rs%bnZ6Cv0mcJEe=J*9U$=U%!Mg?0@rpEJOCy&1Zh`c5n3*B137VMY z7kMrGM0iI6c!T#(M41_SbQWC1t#sDR78aFaL1E}7XXl)7uDF;jI1=m82{{5gbhY`t zJ5-q)z#FIVuIL2~W>ne32=Z8SkORzvD75Js)TZ_7H7liAmvrmmg4MeS?-rPUv$UHU zuOID#t|K-|W+`1*)D2K_`TUFL=3bb&qF$bP_3F%(`I%`3Hq+$~-)^eQeH3h*&`_(> zA=abKv99MGL8!muLT#OX$@=k6tll)dTR_}Ut>+SLj-=8PT~(cSRb^2G+{aWE4;wt| zp%qnCt(Z|&vL99@14xFE;7$m7{`(v?6Y2{|5B4JIf{&pCS+Bje#R*HC*b#;B(H*Wv z+Qsnb4p$>FF`U3=l|Zw_XpN8U0M~6COyIU#1rvBWI58B%1&D|+P+@MIMO=79D%VTd z9MuCiSTLGI3-8jkyl0m`t^=Qp$4B+@kFD`)Elx0_E)T~wJpEAJ0@qq7%LP0M#v36P zLn_`7Pq~#fmlTLQGE4B|h+as}CBg*fbp%5<9m(w{xEJyi5cfQcT&EFUgX8cSs^s2f zAh-l|XDcvecTr~HL^IxJVLjvfKrpyC_?!Os`ZppE4?gp|qaPgIh`inXP=01}p&}o( zWoWhvI9j;zzrgJN(9SOqzp(1VzGmX`&jDODQJEdEVD)M!)QCIk{nq%kU=WGNgHTSO z(lVh^HO&w`4FXUyMMCt93E2WEWy})MA;wvNA-J1MM5&BP4AyW&v~w~Ew*(^+B4MY* zg3ccn$n((jAIM#M^2HTdK+r&-ICvJHeg%kEf^lW*KqWqA$Hzd59X^2`+Lbh#j_f=K&+XJqBzpq}CEG(=8*wqgv?WIyAxV8glxPxGi{Hbzn6?t?g42 z*Mu9cl)1gRX@6}ep8oKfphjS8^fJ5MJ89ectKK6!R@d!ST78*K07H1g7{il%RnnpJbN!_--MesrCp zmXm({X9rSq{ux-v_z=iC_aBKnI(~1pGJMt^KD!aKr6UiAPW&}&4b4`DX6>QbjSgE< z_RcN8*V~>vvk`%$*p?=|w*%n#Y^ncK`OxNT_jF61tjLqLJh|r$GvFYlL0cNMq(M0H zT6xIE1~&3_8<^;Kd0--Y1{P{!U|>i$8W~umrGZ8LaZn2blNuNey2rNia{;3zY5Bea|w}j!jz7f%R)ele=E>rBvCES ziOAI;6=B5<&&*KwhDlikX%A86^aE@+U>V~FKp=SS?%$lMbRV|64{wMM<-Xr8Zw*)C z$L;v>iacV=BVRTDz;ud=Vh3*g@lIWI;!73-(MgkZ5%LAzn?BZycLB<)^@20;yscio zETt}Pt$R)Yf!AUj_j&wY6V{+8=6AvDFAd0AXl+^XAkIW?K%FHAsWwe5StR01RtN?a zR1*lgHIE{QG(=!=!0R|fg8>ol_F{nn>R&yJs%*V#%vwH)de}-UeC#VQw~+(VBDDdn z`O&@jpAP-`dS&9AJ#o$&nX(R@uXIn_-P2z}kB)8auSlb|G-|nT>RWuJ-xfv<2KV(D zHLYo3vK74Zp+7^-XQn=Jw9?TUWuQwa9b9w0HxRrYz(m(uz1He- ztU3+D9L7_&a-RZkAhz%ZtcDSkjL#|1gMh|Ta|vm$e&KNmjdX~m4j#Yo2JER}rZ7ak zLvRzWlfd=M&wfdjs(K21=4Y^=u?FM`{KU}K{MKwm9|{p)N1sGdO070dino>NF4tPZ*ea-d+$UBc!-yS zWb=&Of8sF?e6}|_#rGh0ki2uo-Z%Uh2R_>yJvPWgG#w=GT(kR*J;s60_C{ag2YAqz vAgOZ00B)qpGR1RZY@_^%aKI7{Y>V>xi+3*k{NkO9>-4oFaN>P{wCAAY(d13PUi1CZpd!vo9+% literal 0 HcmV?d00001 diff --git a/src/stacky/tests/test_utils/__pycache__/test_config.cpython-311.pyc b/src/stacky/tests/test_utils/__pycache__/test_config.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..1f802395b2f84cbf762abc31db411b67aafc1fa9 GIT binary patch literal 7622 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 A^8f$< literal 0 HcmV?d00001 diff --git a/src/stacky/tests/test_utils/__pycache__/test_shell.cpython-311.pyc b/src/stacky/tests/test_utils/__pycache__/test_shell.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..c86e79221c4653e0dec272b86bd4034477cbe25a GIT binary patch literal 7442 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 H)%kw_;}iPU literal 0 HcmV?d00001 diff --git a/src/stacky/tests/test_utils/test_config.py b/src/stacky/tests/test_utils/test_config.py new file mode 100644 index 0000000..4357c2c --- /dev/null +++ b/src/stacky/tests/test_utils/test_config.py @@ -0,0 +1,104 @@ +#!/usr/bin/env python3 +"""Tests for stacky.utils.config module.""" + +import os +import tempfile +import unittest +from unittest.mock import patch + +from stacky.utils.config import StackyConfig, read_config, get_config + + +class TestStackyConfig(unittest.TestCase): + """Tests for StackyConfig dataclass.""" + + def test_default_values(self): + """Test StackyConfig has correct default values.""" + config = StackyConfig() + self.assertFalse(config.skip_confirm) + self.assertFalse(config.change_to_main) + self.assertFalse(config.change_to_adopted) + self.assertFalse(config.share_ssh_session) + self.assertFalse(config.use_merge) + self.assertTrue(config.use_force_push) + self.assertFalse(config.compact_pr_display) + self.assertTrue(config.enable_stack_comment) + + def test_read_one_config_ui_section(self): + """Test reading UI section from config file.""" + config = StackyConfig() + with tempfile.NamedTemporaryFile(mode='w', suffix='.ini', delete=False) as f: + f.write("[UI]\n") + f.write("skip_confirm = true\n") + f.write("change_to_main = true\n") + f.write("compact_pr_display = true\n") + f.name + try: + config.read_one_config(f.name) + self.assertTrue(config.skip_confirm) + self.assertTrue(config.change_to_main) + self.assertTrue(config.compact_pr_display) + finally: + os.unlink(f.name) + + def test_read_one_config_git_section(self): + """Test reading GIT section from config file.""" + config = StackyConfig() + with tempfile.NamedTemporaryFile(mode='w', suffix='.ini', delete=False) as f: + f.write("[GIT]\n") + f.write("use_merge = true\n") + f.write("use_force_push = false\n") + f.name + try: + config.read_one_config(f.name) + self.assertTrue(config.use_merge) + self.assertFalse(config.use_force_push) + finally: + os.unlink(f.name) + + +class TestReadConfig(unittest.TestCase): + """Tests for read_config function.""" + + @patch("os.path.exists", return_value=False) + @patch("stacky.utils.config.debug") + def test_read_config_no_files(self, mock_debug, mock_exists): + """Test read_config returns defaults when no config files exist.""" + config = read_config() + self.assertIsInstance(config, StackyConfig) + self.assertFalse(config.skip_confirm) + + @patch("os.path.exists", return_value=False) + def test_read_config_with_no_files(self, mock_exists): + """Test read_config returns defaults when no config files exist.""" + # Mock the get_top_level_dir to raise an exception (not in git repo) + with patch("stacky.git.branch.get_top_level_dir", side_effect=Exception("Not in git repo")): + config = read_config() + self.assertIsInstance(config, StackyConfig) + # Should have default values + self.assertFalse(config.skip_confirm) + + +class TestGetConfig(unittest.TestCase): + """Tests for get_config singleton function.""" + + def setUp(self): + """Reset global CONFIG before each test.""" + import stacky.utils.config as config_module + config_module.CONFIG = None + + @patch("stacky.utils.config.read_config") + def test_get_config_caches_result(self, mock_read_config): + """Test get_config caches the config and only reads once.""" + mock_config = StackyConfig(skip_confirm=True) + mock_read_config.return_value = mock_config + + result1 = get_config() + result2 = get_config() + + self.assertEqual(result1, result2) + mock_read_config.assert_called_once() + + +if __name__ == "__main__": + unittest.main() diff --git a/src/stacky/tests/test_utils/test_shell.py b/src/stacky/tests/test_utils/test_shell.py new file mode 100644 index 0000000..3605a60 --- /dev/null +++ b/src/stacky/tests/test_utils/test_shell.py @@ -0,0 +1,122 @@ +#!/usr/bin/env python3 +"""Tests for stacky.utils.shell module.""" + +import shlex +import subprocess +import unittest +from unittest.mock import patch, MagicMock + +from stacky.utils.shell import ( + _check_returncode, run, run_multiline, run_always_return, remove_prefix +) + + +class TestCheckReturnCode(unittest.TestCase): + """Tests for _check_returncode function.""" + + @patch("stacky.utils.shell.die") + def test_check_returncode_zero(self, mock_die): + """Test that zero return code does not call die.""" + sp = subprocess.CompletedProcess(args=["ls"], returncode=0) + _check_returncode(sp, ["ls"]) + mock_die.assert_not_called() + + @patch("stacky.utils.shell.die") + def test_check_returncode_negative(self, mock_die): + """Test that negative return code (signal) calls die with signal info.""" + sp = subprocess.CompletedProcess(args=["ls"], returncode=-1, stderr=b"error") + _check_returncode(sp, ["ls"]) + mock_die.assert_called_once_with( + "Killed by signal {}: {}. Stderr was:\n{}", + 1, shlex.join(["ls"]), "error" + ) + + @patch("stacky.utils.shell.die") + def test_check_returncode_positive(self, mock_die): + """Test that positive return code calls die with exit status.""" + sp = subprocess.CompletedProcess(args=["ls"], returncode=1, stderr=b"error") + _check_returncode(sp, ["ls"]) + mock_die.assert_called_once_with( + "Exited with status {}: {}. Stderr was:\n{}", + 1, shlex.join(["ls"]), "error" + ) + + +class TestRun(unittest.TestCase): + """Tests for run functions.""" + + @patch("subprocess.run") + @patch("stacky.utils.shell.debug") + def test_run_success(self, mock_debug, mock_subprocess_run): + """Test run returns stripped output on success.""" + mock_subprocess_run.return_value = subprocess.CompletedProcess( + args=["echo", "hello"], + returncode=0, + stdout=b" hello world \n", + stderr=b"" + ) + result = run(["echo", "hello"]) + self.assertEqual(result, "hello world") + + @patch("subprocess.run") + @patch("stacky.utils.shell.debug") + def test_run_failure_check_false(self, mock_debug, mock_subprocess_run): + """Test run returns None on failure when check=False.""" + mock_subprocess_run.return_value = subprocess.CompletedProcess( + args=["false"], + returncode=1, + stdout=b"", + stderr=b"error" + ) + result = run(["false"], check=False) + self.assertIsNone(result) + + @patch("subprocess.run") + @patch("stacky.utils.shell.debug") + def test_run_multiline_preserves_newlines(self, mock_debug, mock_subprocess_run): + """Test run_multiline preserves newlines in output.""" + mock_subprocess_run.return_value = subprocess.CompletedProcess( + args=["echo", "-e", "line1\\nline2"], + returncode=0, + stdout=b"line1\nline2\n", + stderr=b"" + ) + result = run_multiline(["echo", "-e", "line1\\nline2"]) + self.assertEqual(result, "line1\nline2\n") + + @patch("subprocess.run") + @patch("stacky.utils.shell.debug") + def test_run_always_return_asserts_not_none(self, mock_debug, mock_subprocess_run): + """Test run_always_return returns output (asserts not None).""" + mock_subprocess_run.return_value = subprocess.CompletedProcess( + args=["echo", "test"], + returncode=0, + stdout=b"test", + stderr=b"" + ) + result = run_always_return(["echo", "test"]) + self.assertEqual(result, "test") + + +class TestRemovePrefix(unittest.TestCase): + """Tests for remove_prefix function.""" + + def test_remove_prefix_success(self): + """Test remove_prefix removes prefix correctly.""" + result = remove_prefix("refs/heads/main", "refs/heads/") + self.assertEqual(result, "main") + + def test_remove_prefix_full_match(self): + """Test remove_prefix with exact match returns empty string.""" + result = remove_prefix("prefix", "prefix") + self.assertEqual(result, "") + + @patch("stacky.utils.shell.die") + def test_remove_prefix_no_match(self, mock_die): + """Test remove_prefix dies when prefix not found.""" + remove_prefix("other/path", "refs/heads/") + mock_die.assert_called_once() + + +if __name__ == "__main__": + unittest.main() diff --git a/src/stacky/utils/__init__.py b/src/stacky/utils/__init__.py new file mode 100644 index 0000000..50ab185 --- /dev/null +++ b/src/stacky/utils/__init__.py @@ -0,0 +1 @@ +# Utils module - shared utilities for stacky diff --git a/src/stacky/utils/config.py b/src/stacky/utils/config.py new file mode 100644 index 0000000..1764d89 --- /dev/null +++ b/src/stacky/utils/config.py @@ -0,0 +1,71 @@ +"""Configuration management for stacky.""" + +import configparser +import dataclasses +import os +from typing import Optional + +from stacky.utils.logging import debug + + +@dataclasses.dataclass +class StackyConfig: + """Configuration options for stacky.""" + skip_confirm: bool = False + change_to_main: bool = False + change_to_adopted: bool = False + share_ssh_session: bool = False + use_merge: bool = False + use_force_push: bool = True + compact_pr_display: bool = False + enable_stack_comment: bool = True + + def read_one_config(self, config_path: str): + """Read configuration from a single file.""" + rawconfig = configparser.ConfigParser() + rawconfig.read(config_path) + if rawconfig.has_section("UI"): + self.skip_confirm = rawconfig.getboolean("UI", "skip_confirm", fallback=self.skip_confirm) + self.change_to_main = rawconfig.getboolean("UI", "change_to_main", fallback=self.change_to_main) + self.change_to_adopted = rawconfig.getboolean("UI", "change_to_adopted", fallback=self.change_to_adopted) + self.share_ssh_session = rawconfig.getboolean("UI", "share_ssh_session", fallback=self.share_ssh_session) + self.compact_pr_display = rawconfig.getboolean("UI", "compact_pr_display", fallback=self.compact_pr_display) + self.enable_stack_comment = rawconfig.getboolean("UI", "enable_stack_comment", fallback=self.enable_stack_comment) + + if rawconfig.has_section("GIT"): + self.use_merge = rawconfig.getboolean("GIT", "use_merge", fallback=self.use_merge) + self.use_force_push = rawconfig.getboolean("GIT", "use_force_push", fallback=self.use_force_push) + + +# Global config singleton +CONFIG: Optional[StackyConfig] = None + + +def get_config() -> StackyConfig: + """Get the global configuration, loading it if necessary.""" + global CONFIG + if CONFIG is None: + CONFIG = read_config() + return CONFIG + + +def read_config() -> StackyConfig: + """Read configuration from config files.""" + config = StackyConfig() + config_paths = [os.path.expanduser("~/.stackyconfig")] + + try: + from stacky.git.branch import get_top_level_dir + 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) + + return config diff --git a/src/stacky/utils/logging.py b/src/stacky/utils/logging.py new file mode 100644 index 0000000..cbce0b0 --- /dev/null +++ b/src/stacky/utils/logging.py @@ -0,0 +1,77 @@ +"""Logging and output utilities for stacky.""" + +import logging +import os +import sys + +import colors # type: ignore + +_LOGGING_FORMAT = "%(asctime)s %(module)s %(levelname)s: %(message)s" + +# Terminal state - can be modified by main() +COLOR_STDOUT: bool = os.isatty(1) +COLOR_STDERR: bool = os.isatty(2) +IS_TERMINAL: bool = os.isatty(1) and os.isatty(2) + + +def set_color_mode(mode: str): + """Set color mode: 'always', 'auto', or 'never'.""" + global COLOR_STDOUT, COLOR_STDERR + if mode == "always": + COLOR_STDOUT = True + COLOR_STDERR = True + elif mode == "never": + COLOR_STDOUT = False + COLOR_STDERR = False + # 'auto' keeps the default based on isatty + + +def fmt(s: str, *args, color: bool = False, fg=None, bg=None, style=None, **kwargs) -> str: + """Format a string with optional color.""" + s = colors.color(s, fg=fg, bg=bg, style=style) if color else s + return s.format(*args, **kwargs) + + +def cout(*args, **kwargs): + """Write colored output to stdout.""" + return sys.stdout.write(fmt(*args, color=COLOR_STDOUT, **kwargs)) + + +def _log(fn, *args, **kwargs): + """Internal log helper.""" + return fn("%s", fmt(*args, color=COLOR_STDERR, **kwargs)) + + +def debug(*args, **kwargs): + """Log debug message.""" + return _log(logging.debug, *args, fg="green", **kwargs) + + +def info(*args, **kwargs): + """Log info message.""" + return _log(logging.info, *args, fg="green", **kwargs) + + +def warning(*args, **kwargs): + """Log warning message.""" + return _log(logging.warning, *args, fg="yellow", **kwargs) + + +def error(*args, **kwargs): + """Log error message.""" + return _log(logging.error, *args, fg="red", **kwargs) + + +class ExitException(BaseException): + """Exception raised when the program should exit.""" + def __init__(self, fmt, *args, **kwargs): + super().__init__(fmt.format(*args, **kwargs)) + + +def die(*args, **kwargs): + """Exit with an error message. Stops SSH mux if active.""" + # Import here to avoid circular dependency + from stacky.git.remote import stop_muxed_ssh + # We are taking a wild guess at what is the remote ... + stop_muxed_ssh() + raise ExitException(*args, **kwargs) diff --git a/src/stacky/utils/shell.py b/src/stacky/utils/shell.py new file mode 100644 index 0000000..6025e81 --- /dev/null +++ b/src/stacky/utils/shell.py @@ -0,0 +1,61 @@ +"""Shell execution utilities for stacky.""" + +import shlex +import subprocess +import sys +from typing import Optional + +from stacky.utils.logging import debug, die +from stacky.utils.types import CmdArgs + + +def _check_returncode(sp: subprocess.CompletedProcess, cmd: CmdArgs): + """Check the return code of a subprocess and die if non-zero.""" + rc = sp.returncode + if rc == 0: + return + stderr = sp.stderr.decode("UTF-8") + if rc < 0: + die("Killed by signal {}: {}. Stderr was:\n{}", -rc, shlex.join(cmd), stderr) + else: + die("Exited with status {}: {}. Stderr was:\n{}", rc, shlex.join(cmd), stderr) + + +def run_multiline(cmd: CmdArgs, *, check: bool = True, null: bool = True, out: bool = False) -> Optional[str]: + """Run a command and return its output (with newlines preserved).""" + debug("Running: {}", shlex.join(cmd)) + sys.stdout.flush() + sys.stderr.flush() + sp = subprocess.run( + cmd, + stdout=1 if out else subprocess.PIPE, + stderr=subprocess.PIPE, + ) + if check: + _check_returncode(sp, cmd) + rc = sp.returncode + if rc != 0: + return None + if sp.stdout is None: + return "" + return sp.stdout.decode("UTF-8") + + +def run_always_return(cmd: CmdArgs, **kwargs) -> str: + """Run a command and always return output (asserts it's not None).""" + out = run(cmd, **kwargs) + assert out is not None + return out + + +def run(cmd: CmdArgs, **kwargs) -> Optional[str]: + """Run a command and return stripped output.""" + out = run_multiline(cmd, **kwargs) + return None if out is None else out.strip() + + +def remove_prefix(s: str, prefix: str) -> str: + """Remove a prefix from a string, dying if not present.""" + if not s.startswith(prefix): + die('Invalid string "{}": expected prefix "{}"', s, prefix) + return s[len(prefix):] diff --git a/src/stacky/utils/types.py b/src/stacky/utils/types.py new file mode 100644 index 0000000..224038c --- /dev/null +++ b/src/stacky/utils/types.py @@ -0,0 +1,39 @@ +"""Type aliases and constants for stacky.""" + +import logging +import os +from typing import Dict, FrozenSet, List, NewType, Tuple, Union + +# Type aliases +BranchName = NewType("BranchName", str) +PathName = NewType("PathName", str) +Commit = NewType("Commit", str) +CmdArgs = NewType("CmdArgs", List[str]) + +# Forward reference types (actual types defined in stack/models.py) +# These are used for type hints only +StackSubTree = Tuple["StackBranch", "BranchesTree"] # type: ignore +TreeNode = Tuple[BranchName, StackSubTree] +BranchesTree = NewType("BranchesTree", Dict[BranchName, StackSubTree]) +BranchesTreeForest = NewType("BranchesTreeForest", List[BranchesTree]) + +JSON = Union[Dict[str, "JSON"], List["JSON"], str, int, float, bool, None] + +# Constants +MAX_SSH_MUX_LIFETIME = 120 # 2 minutes ought to be enough for anybody ;-) +STATE_FILE = os.path.expanduser("~/.stacky.state") +TMP_STATE_FILE = STATE_FILE + ".tmp" + +# Stack bottoms - mutable set that can be extended +STACK_BOTTOMS: set[BranchName] = set([BranchName("master"), BranchName("main")]) +FROZEN_STACK_BOTTOMS: FrozenSet[BranchName] = frozenset([BranchName("master"), BranchName("main")]) + +# Log levels +LOGLEVELS = { + "critical": logging.CRITICAL, + "error": logging.ERROR, + "warn": logging.WARNING, + "warning": logging.WARNING, + "info": logging.INFO, + "debug": logging.DEBUG, +} diff --git a/src/stacky/utils/ui.py b/src/stacky/utils/ui.py new file mode 100644 index 0000000..c24e039 --- /dev/null +++ b/src/stacky/utils/ui.py @@ -0,0 +1,94 @@ +"""User interface utilities for stacky.""" + +import os +import sys +from typing import TYPE_CHECKING + +import asciitree # type: ignore +from simple_term_menu import TerminalMenu # type: ignore + +from stacky.utils.config import get_config +from stacky.utils.logging import IS_TERMINAL, cout, die + +if TYPE_CHECKING: + from stacky.stack.models import StackBranch + from stacky.utils.types import BranchesTreeForest + + +def prompt(message: str, default_value: str | None) -> str: + """Prompt the user for input.""" + cout(message) + if default_value is not None: + cout("({})", default_value, fg="gray") + cout(" ") + while True: + sys.stderr.flush() + r = input().strip() + + if len(r) > 0: + return r + if default_value: + return default_value + + +def confirm(msg: str = "Proceed?"): + """Ask for confirmation. Skips if skip_confirm is set.""" + if get_config().skip_confirm: + return + if not os.isatty(0): + die("Standard input is not a terminal, use --force option to force action") + print() + while True: + cout("{} [yes/no] ", msg, fg="yellow") + sys.stderr.flush() + r = input().strip().lower() + if r == "yes" or r == "y": + break + if r == "no": + die("Not confirmed") + cout("Please answer yes or no\n", fg="red") + + +# Print upside down, to match our "upstack" / "downstack" nomenclature +_ASCII_TREE_BOX = { + "UP_AND_RIGHT": "\u250c", + "HORIZONTAL": "\u2500", + "VERTICAL": "\u2502", + "VERTICAL_AND_RIGHT": "\u251c", +} +_ASCII_TREE_STYLE = asciitree.drawing.BoxStyle(gfx=_ASCII_TREE_BOX) +ASCII_TREE = asciitree.LeftAligned(draw=_ASCII_TREE_STYLE) + + +def menu_choose_branch(forest: "BranchesTreeForest") -> "StackBranch": + """Display a menu for choosing a branch from the forest.""" + # Import here to avoid circular dependency + from stacky.stack.tree import forest_depth_first, format_tree + + if not IS_TERMINAL: + die("May only choose from menu when using a terminal") + + s = "" + lines = [] + for tree in forest: + s = ASCII_TREE(format_tree(tree)) + lines += [l.rstrip() for l in s.split("\n")] + lines.reverse() + + # Find current branch marker + from stacky.git.branch import get_current_branch_name + current = get_current_branch_name() + initial_index = 0 + for i, l in enumerate(lines): + if "*" in l: # lol + initial_index = i + break + + menu = TerminalMenu(lines, cursor_index=initial_index) + idx = menu.show() + if idx is None: + die("Aborted") + + branches = list(forest_depth_first(forest)) + branches.reverse() + return branches[idx]