Skip to content
Open
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
24 changes: 23 additions & 1 deletion docs/restack.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ revup restack - Reorder commits to group topics together.

`revup [--verbose] [--keep-temp]`
: `restack [--help] [--base-branch=<base>] [--num-commits=<N>]`
`[--relative-chain]`
`[--relative-chain] [--as <topic> [<topic> ...]]`

# DESCRIPTION

Expand Down Expand Up @@ -48,3 +48,25 @@ tags when you know all reviews in the stack will be dependent.
**--topicless-last, -t**
: Apply all topicless commits last (at the top of the commit stack) instead
of first.

**--as <topic> [<topic> ...]**
: Reorder the specified topics into a chain before restacking. The first
topic in the list keeps its current ancestor (or becomes relative to the
base branch if all its ancestors are in the list). Each subsequent topic
becomes relative to the previous topic in the list.

For example, if you have topics `a <- b <- c` (c relative to b, b relative
to a) and run `restack --as c b`, the result will be `a <- c <- b` (b
relative to c, c relative to a).

This is useful for reordering dependent reviews without manually editing
the `Relative:` tags in each commit. Topics not in the list are unaffected,
though they may end up branching off if their relative was reordered.

Examples:

: `revup restack --as feature_b feature_a`
: Swap the order of two topics, making feature_a relative to feature_b.

: `revup restack --as topic_c topic_b topic_a`
: Reverse the order of three topics in a chain.
1 change: 1 addition & 0 deletions revup/amend.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import argparse
import asyncio
import logging
import os
import re
import shlex
import subprocess
Expand Down
132 changes: 132 additions & 0 deletions revup/restack.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,123 @@
import argparse
from typing import Optional

from revup import git, topic_stack
from revup.relative_utils import update_topic_relative_in_stack
from revup.types import RevupUsageException


def find_gca_for_reorder(
first_topic: topic_stack.Topic,
topic_set: set[str],
all_topics: dict[str, topic_stack.Topic],
) -> Optional[topic_stack.Topic]:
"""Find GCA - the ancestor of the lowest topic in topic_set.

Walk the ancestor chain and find the last (closest to base) topic
that's in topic_set. Return that topic's parent as the GCA.

This correctly handles "middle topics" - topics on the path between
reordered topics but not in the reorder list themselves.

Note: This uses topic.tags[TAG_RELATIVE] instead of topic.relative_topic because
relative_topic is not populated until populate_reviews() is called.

Args:
first_topic: The first topic in the reorder list
topic_set: Set of topic names being reordered
all_topics: Dict of all topics by name

Returns:
The ancestor topic of the lowest topic in the set, or None if the
lowest topic has no ancestor (meaning the chain should start from base branch)
"""
# Walk entire chain, tracking the lowest topic in the set
lowest_in_set = None
current = first_topic

while True:
if current.name in topic_set:
lowest_in_set = current

relative_names = current.tags.get(topic_stack.TAG_RELATIVE, set())
if not relative_names:
break # Reached base

relative_name = next(iter(relative_names))
if relative_name not in all_topics:
break # Relative not found

current = all_topics[relative_name]

# GCA is the parent of the lowest topic in the set
if lowest_in_set is None:
return None

relative_names = lowest_in_set.tags.get(topic_stack.TAG_RELATIVE, set())
if not relative_names:
return None

relative_name = next(iter(relative_names))
return all_topics.get(relative_name)


async def reorder_topics(
git_ctx: git.Git,
topics: topic_stack.TopicStack,
reorder_topic_names: list[str],
) -> None:
"""Reorder specified topics into a chain, updating Relative: tags.

Args:
git_ctx: Git context
topics: The TopicStack with populated topics
reorder_topic_names: List of topic names in desired order
"""
# Validate all topics exist
for name in reorder_topic_names:
if name not in topics.topics:
available = ", ".join(sorted(topics.topics.keys())) if topics.topics else "(none)"
raise RevupUsageException(
f"Topic '{name}' not found.\nAvailable topics: {available}"
)

topic_set = set(reorder_topic_names)

# Find GCA - first ancestor of first topic NOT in the set
first_topic = topics.topics[reorder_topic_names[0]]
gca = find_gca_for_reorder(first_topic, topic_set, topics.topics)

# Update each topic's relative in the commit stack
changed = False
for i, name in enumerate(reorder_topic_names):
topic = topics.topics[name]
if i == 0:
new_relative = gca
else:
new_relative = topics.topics[reorder_topic_names[i - 1]]

# Get current relative from tags (not relative_topic which isn't populated yet)
current_relative_names = topic.tags.get(topic_stack.TAG_RELATIVE, set())
current_relative_name = next(iter(current_relative_names), None) if current_relative_names else None
new_relative_name = new_relative.name if new_relative else None

# Skip if already pointing to correct relative
if current_relative_name == new_relative_name:
continue

if update_topic_relative_in_stack(topic, new_relative, topics.commits, prompt=False):
changed = True

if changed:
# Rewrite commits with updated messages
new_parent = topics.commits[0].parents[0]
for commit in topics.commits:
new_parent = await git_ctx.synthetic_cherry_pick_from_commit(commit, new_parent)

git_env = {
"GIT_REFLOG_ACTION": "reset --soft (revup restack --as)",
}
await git_ctx.soft_reset(new_parent, git_env)


async def main(args: argparse.Namespace, git_ctx: git.Git) -> int:
Expand All @@ -16,6 +133,21 @@ async def main(args: argparse.Namespace, git_ctx: git.Git) -> int:
)

await topics.populate_topics()

# Handle --as option to reorder topics
if args.reorder_topics:
await reorder_topics(git_ctx, topics, args.reorder_topics)

# Re-create topic stack with rewritten commits
topics = topic_stack.TopicStack(
git_ctx,
args.base_branch,
args.relative_branch,
None,
None,
)
await topics.populate_topics()

await topics.populate_reviews()
await topics.restack(args.topicless_last)
return 0
7 changes: 7 additions & 0 deletions revup/revup.py
Original file line number Diff line number Diff line change
Expand Up @@ -253,6 +253,13 @@ async def main() -> int:
upload_parser.add_argument("--head", default="HEAD")

restack_parser.add_argument("--topicless-last", "-t", action="store_true")
restack_parser.add_argument(
"--as",
nargs="+",
dest="reorder_topics",
metavar="TOPIC",
help="Reorder specified topics into a chain, updating Relative: tags",
)

amend_parser.add_argument("ref_or_topic", nargs="?")
amend_parser.add_argument("--edit", "-s", default=True, action="store_true")
Expand Down