Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 10 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,8 @@ Syntax is as follows:
- `stacky branch commit <name> [-m <message>] [-a]`: create a new branch and commit changes in one command
- `stacky commit [-m <message>] [--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
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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:

Expand All @@ -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)
280 changes: 274 additions & 6 deletions src/stacky/stacky.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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`
Expand All @@ -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()