Skip to content

Commit 2f9429f

Browse files
committed
Add --interactive option to rebase
`stg rebase --interactive` is a new tool which mirrors `git rebase --interactive`. Using an editor `--interactive` lets the user [reorder patches](https://asciinema.org/a/421486), [squash patch chains](https://asciinema.org/a/421488), or [delete individual patches](https://asciinema.org/a/421487) (links go to recordings showing these features!). Further instructions could be added but this seemed like a good base set. I've been using this locally and it helps execute certain sets of commands much faster! Tests are included. Signed-off-by: Topher Brown <[email protected]>
1 parent 150ec62 commit 2f9429f

File tree

5 files changed

+378
-30
lines changed

5 files changed

+378
-30
lines changed

stgit/commands/common.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -322,6 +322,24 @@ def post_rebase(stack, applied, cmd_name, check_merged):
322322
return trans.run(iw)
323323

324324

325+
def delete_patches(stack, iw, patches):
326+
def allow_conflicts(trans):
327+
# Allow conflicts if the topmost patch stays the same.
328+
if stack.patchorder.applied:
329+
return trans.applied and trans.applied[-1] == stack.patchorder.applied[-1]
330+
else:
331+
return not trans.applied
332+
333+
trans = StackTransaction(stack, 'delete', allow_conflicts=allow_conflicts)
334+
try:
335+
to_push = trans.delete_patches(lambda pn: pn in patches)
336+
for pn in to_push:
337+
trans.push_patch(pn, iw)
338+
except TransactionHalted:
339+
pass
340+
return trans.run(iw)
341+
342+
325343
#
326344
# Patch description/e-mail/diff parsing
327345
#

stgit/commands/delete.py

Lines changed: 7 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
from stgit.argparse import opt, patch_range
2-
from stgit.commands.common import CmdException, DirectoryHasRepository, parse_patches
3-
from stgit.lib import transaction
2+
from stgit.commands.common import (
3+
CmdException,
4+
DirectoryHasRepository,
5+
delete_patches,
6+
parse_patches,
7+
)
48

59
__copyright__ = """
610
Copyright (C) 2005, Catalin Marinas <[email protected]>
@@ -78,20 +82,4 @@ def func(parser, options, args):
7882
parser.error('Can only spill topmost applied patches')
7983
iw = None # don't touch index+worktree
8084

81-
def allow_conflicts(trans):
82-
# Allow conflicts if the topmost patch stays the same.
83-
if stack.patchorder.applied:
84-
return trans.applied and trans.applied[-1] == stack.patchorder.applied[-1]
85-
else:
86-
return not trans.applied
87-
88-
trans = transaction.StackTransaction(
89-
stack, 'delete', allow_conflicts=allow_conflicts
90-
)
91-
try:
92-
to_push = trans.delete_patches(lambda pn: pn in patches)
93-
for pn in to_push:
94-
trans.push_patch(pn, iw)
95-
except transaction.TransactionHalted:
96-
pass
97-
return trans.run(iw)
85+
return delete_patches(stack, iw, patches)

stgit/commands/rebase.py

Lines changed: 172 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,22 @@
1+
import copy
2+
import itertools
3+
import re
4+
import typing
5+
from enum import Enum
6+
7+
from stgit import utils
18
from stgit.argparse import opt
29
from stgit.commands.common import (
310
CmdException,
411
DirectoryGotoTopLevel,
12+
delete_patches,
513
git_commit,
614
post_rebase,
715
prepare_rebase,
816
rebase,
917
)
18+
from stgit.commands.squash import squash
19+
from stgit.utils import edit_string
1020

1121
__copyright__ = """
1222
Copyright (C) 2005, Catalin Marinas <[email protected]>
@@ -26,10 +36,10 @@
2636

2737
help = 'Move the stack base to another point in history'
2838
kind = 'stack'
29-
usage = ['[options] [--] <new-base-id>']
39+
usage = ['[options] [--] [new-base-id]']
3040
description = """
3141
Pop all patches from current stack, move the stack base to the given
32-
<new-base-id> and push the patches back.
42+
[new-base-id] and push the patches back.
3343
3444
If you experience merge conflicts, resolve the problem and continue
3545
the rebase by executing the following sequence:
@@ -45,6 +55,12 @@
4555

4656
args = ['commit']
4757
options = [
58+
opt(
59+
'-i',
60+
'--interactive',
61+
action='store_true',
62+
short='Open an interactive editor to manipulate patches',
63+
),
4864
opt(
4965
'-n',
5066
'--nopush',
@@ -61,11 +77,153 @@
6177

6278
directory = DirectoryGotoTopLevel()
6379

80+
INTERACTIVE_APPLY_LINE = '# --- APPLY_LINE ---'
81+
82+
INTERACTIVE_HELP_INSTRUCTIONS = """
83+
# Commands:
84+
# k, keep <patch> = do not modify this patch
85+
# s, squash <patch> = squash patch into the previous patch
86+
# d, delete <patch> = delete patch
87+
#
88+
# These lines can be re-ordered; they are executed from top to bottom.
89+
#
90+
# Patches above the APPLY_LINE are applied; other patches are kept unapplied.
91+
"""
92+
93+
94+
def __get_description(stack, patch):
95+
"""Extract and return a patch's short description"""
96+
cd = stack.patches[patch].data
97+
return cd.message_str.strip().split('\n', 1)[0].rstrip()
98+
99+
100+
class Action(Enum):
101+
# do nothing; a 'no-op'
102+
KEEP = 0
103+
# delete the patch
104+
DELETE = 1
105+
# squash the patch into the previous patch
106+
SQUASH = 2
107+
108+
109+
Instruction = typing.NamedTuple(
110+
'Instruction', [('patch_name', str), ('action', Action), ('apply', bool)]
111+
)
112+
113+
114+
def __do_rebase_interactive(repository, previously_applied_patches, check_merged):
115+
"""Opens an interactive editor, generates instruction list, and executes instructions."""
116+
stack = repository.get_stack()
117+
118+
if len(stack.patchorder.all) == 0:
119+
return utils.STGIT_SUCCESS
120+
121+
name_len = max((len(s) for s in stack.patchorder.all))
122+
line = 'keep {{:<{name_len}}} # {{}}'.format(name_len=name_len)
123+
124+
# create a list of all patches to send to the editor
125+
instructions = []
126+
for pn in previously_applied_patches:
127+
patch = (pn, __get_description(stack, pn))
128+
instructions.append(line.format(*patch))
129+
instructions.append(INTERACTIVE_APPLY_LINE)
130+
for pn in stack.patchorder.all:
131+
if pn in previously_applied_patches:
132+
continue
133+
patch = (pn, __get_description(stack, pn))
134+
instructions.append(line.format(*patch))
135+
instructions.append(INTERACTIVE_HELP_INSTRUCTIONS)
136+
137+
# open an editor to let the user generate the 'todo' instructions
138+
todo = edit_string(
139+
'\n'.join(instructions),
140+
'.stgit-rebase-interactive.txt',
141+
)
142+
143+
# parse the instructions we've been given
144+
seen_apply_line = False
145+
instructions = []
146+
for line in todo.split('\n'):
147+
line = line.strip()
148+
if line == INTERACTIVE_APPLY_LINE:
149+
seen_apply_line = True
150+
151+
# ignore comment lines
152+
if not line or line.startswith('#'):
153+
continue
154+
155+
# parse a single instruction
156+
match = re.match(r'(\S+) (\S+).*', line)
157+
if not match:
158+
raise CmdException("Bad todo line: '%s'" % line)
159+
instruction_str, patch_name = match.groups()
160+
if patch_name not in stack.patchorder.all:
161+
raise CmdException("Bad patch name '%s'" % patch_name)
162+
if instruction_str in ('k', 'keep'):
163+
instruction_type = Action.KEEP
164+
elif instruction_str in ('d', 'delete'):
165+
instruction_type = Action.DELETE
166+
elif instruction_str in ('s', 'squash'):
167+
instruction_type = Action.SQUASH
168+
else:
169+
raise CmdException("Unknown instruction '%s'" % instruction_str)
170+
171+
# save the instruction to execute later
172+
instructions.append(
173+
Instruction(patch_name, instruction_type, not seen_apply_line)
174+
)
175+
176+
# execute all the non-squash instructions first. we use a copy to make sure
177+
# we don't skip any instructions as we delete from the list
178+
for instruction in copy.copy(instructions):
179+
if instruction.apply:
180+
post_rebase(stack, {instruction.patch_name}, 'rebase', check_merged)
181+
if instruction.action == Action.DELETE:
182+
delete_patches(stack, stack.repository.default_iw, {instruction.patch_name})
183+
instructions.remove(instruction)
184+
185+
# execute squashes last because patch names may change during squashes. this
186+
# is a double loop so we can handle multiple chains of multiple squashes
187+
for index in itertools.count():
188+
if index >= len(instructions):
189+
break # reached the end of the instruction list
190+
# determine how many of the next N instructions are squashes
191+
squashes_found = False
192+
for num_squashes in itertools.count(1):
193+
if len(instructions) <= index + num_squashes:
194+
break # reached the end of the instruction list
195+
if instructions[index + num_squashes].action == Action.SQUASH:
196+
squashes_found = True
197+
else:
198+
break # not a squash; chain is over
199+
# execute squash
200+
if squashes_found:
201+
squashes = instructions[index : index + num_squashes]
202+
squash(
203+
stack,
204+
stack.repository.default_iw,
205+
None,
206+
None,
207+
None,
208+
[p.patch_name for p in squashes],
209+
)
210+
# remove the squashed patches from the instruction set
211+
instructions = (
212+
instructions[: index + 1] + instructions[index + num_squashes :]
213+
)
214+
return utils.STGIT_SUCCESS
215+
64216

65217
def func(parser, options, args):
66218
"""Rebase the current stack"""
67-
if len(args) != 1:
68-
parser.error('incorrect number of arguments')
219+
220+
if options.interactive:
221+
# destination is optional for '--interactive'
222+
if len(args) not in (0, 1):
223+
parser.error('incorrect number of arguments')
224+
else:
225+
if len(args) != 1:
226+
parser.error('incorrect number of arguments')
69227

70228
repository = directory.repository
71229
stack = repository.get_stack()
@@ -74,14 +232,19 @@ def func(parser, options, args):
74232
if stack.protected:
75233
raise CmdException('This branch is protected. Rebase is not permitted')
76234

77-
target = git_commit(args[0], repository)
235+
if len(args) > 0:
236+
target = git_commit(args[0], repository)
237+
else:
238+
target = stack.base
78239

79240
applied = stack.patchorder.applied
80-
81241
retval = prepare_rebase(stack, 'rebase')
82242
if retval:
83243
return retval
84-
85244
rebase(stack, iw, target)
86-
if not options.nopush:
87-
return post_rebase(stack, applied, 'rebase', check_merged=options.merged)
245+
246+
if options.interactive:
247+
return __do_rebase_interactive(repository, applied, check_merged=options.merged)
248+
else:
249+
if not options.nopush:
250+
return post_rebase(stack, applied, 'rebase', check_merged=options.merged)

stgit/commands/squash.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -106,7 +106,7 @@ def _squash_patches(trans, patches, msg, save_template, no_verify=False):
106106
return cd
107107

108108

109-
def _squash(stack, iw, name, msg, save_template, patches, no_verify=False):
109+
def squash(stack, iw, name, msg, save_template, patches, no_verify=False):
110110
# If a name was supplied on the command line, make sure it's OK.
111111
if name and name not in patches and name in stack.patches:
112112
raise CmdException('Patch name "%s" already taken' % name)
@@ -162,7 +162,7 @@ def func(parser, options, args):
162162
raise CmdException('Need at least two patches')
163163
if options.name and not stack.patches.is_name_valid(options.name):
164164
raise CmdException('Patch name "%s" is invalid' % options.name)
165-
return _squash(
165+
return squash(
166166
stack,
167167
stack.repository.default_iw,
168168
options.name,

0 commit comments

Comments
 (0)