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()