Skip to content

Commit e217ebb

Browse files
topher200jpgrayson
authored andcommitted
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 c061ec1 commit e217ebb

File tree

5 files changed

+393
-29
lines changed

5 files changed

+393
-29
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: 175 additions & 8 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,158 @@
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.splitlines():
147+
line = line.strip()
148+
149+
# record when we find the APPLY_LINE so we know which patches to apply
150+
if INTERACTIVE_APPLY_LINE in line:
151+
if INTERACTIVE_APPLY_LINE == line:
152+
seen_apply_line = True
153+
else:
154+
raise CmdException("Bad APPLY_LINE: '%s'" % line)
155+
156+
# ignore comment lines
157+
if not line or line.startswith('#'):
158+
continue
159+
160+
# parse a single instruction
161+
match = re.match(r'(\S+) (\S+).*', line)
162+
if not match:
163+
raise CmdException("Bad todo line: '%s'" % line)
164+
instruction_str, patch_name = match.groups()
165+
if patch_name not in stack.patchorder.all:
166+
raise CmdException("Bad patch name '%s'" % patch_name)
167+
if instruction_str in ('k', 'keep'):
168+
instruction_type = Action.KEEP
169+
elif instruction_str in ('d', 'delete'):
170+
instruction_type = Action.DELETE
171+
elif instruction_str in ('s', 'squash'):
172+
instruction_type = Action.SQUASH
173+
else:
174+
raise CmdException("Unknown instruction '%s'" % instruction_str)
175+
176+
# save the instruction to execute later
177+
instructions.append(
178+
Instruction(patch_name, instruction_type, not seen_apply_line)
179+
)
180+
181+
# execute all the non-squash instructions first. we use a copy to make sure
182+
# we don't skip any instructions as we delete from the list
183+
for instruction in copy.copy(instructions):
184+
if instruction.apply:
185+
post_rebase(stack, {instruction.patch_name}, 'rebase', check_merged)
186+
if instruction.action == Action.DELETE:
187+
delete_patches(stack, stack.repository.default_iw, {instruction.patch_name})
188+
instructions.remove(instruction)
189+
190+
# execute squashes last because patch names may change during squashes. this
191+
# is a double loop so we can handle multiple chains of multiple squashes
192+
for index in itertools.count():
193+
if index >= len(instructions):
194+
break # reached the end of the instruction list
195+
# determine how many of the next N instructions are squashes
196+
squashes_found = False
197+
for num_squashes in itertools.count(1):
198+
if len(instructions) <= index + num_squashes:
199+
break # reached the end of the instruction list
200+
if instructions[index + num_squashes].action == Action.SQUASH:
201+
squashes_found = True
202+
else:
203+
break # not a squash; chain is over
204+
# execute squash
205+
if squashes_found:
206+
squashes = instructions[index : index + num_squashes]
207+
squash(
208+
stack,
209+
stack.repository.default_iw,
210+
None,
211+
None,
212+
None,
213+
[p.patch_name for p in squashes],
214+
)
215+
# remove the squashed patches from the instruction set
216+
instructions = (
217+
instructions[: index + 1] + instructions[index + num_squashes :]
218+
)
219+
return utils.STGIT_SUCCESS
220+
64221

65222
def func(parser, options, args):
66223
"""Rebase the current stack"""
67-
if len(args) != 1:
68-
parser.error('incorrect number of arguments')
224+
225+
if options.interactive:
226+
# destination is optional for '--interactive'
227+
if len(args) not in (0, 1):
228+
parser.error('incorrect number of arguments')
229+
else:
230+
if len(args) != 1:
231+
parser.error('incorrect number of arguments')
69232

70233
repository = directory.repository
71234
stack = repository.get_stack()
@@ -74,14 +237,18 @@ def func(parser, options, args):
74237
if stack.protected:
75238
raise CmdException('This branch is protected. Rebase is not permitted')
76239

77-
target = git_commit(args[0], repository)
240+
if len(args) > 0:
241+
target = git_commit(args[0], repository)
242+
else:
243+
target = stack.base
78244

79245
applied = stack.patchorder.applied
80-
81246
retval = prepare_rebase(stack, 'rebase')
82247
if retval:
83248
return retval
84-
85249
rebase(stack, iw, target)
86-
if not options.nopush:
250+
251+
if options.interactive:
252+
return __do_rebase_interactive(repository, applied, check_merged=options.merged)
253+
elif not options.nopush:
87254
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
@@ -109,7 +109,7 @@ def _squash_patches(trans, patches, name, msg, save_template, no_verify=False):
109109
return cd
110110

111111

112-
def _squash(stack, iw, name, msg, save_template, patches, no_verify=False):
112+
def squash(stack, iw, name, msg, save_template, patches, no_verify=False):
113113
# If a name was supplied on the command line, make sure it's OK.
114114
if name and name not in patches and name in stack.patches:
115115
raise CmdException('Patch name "%s" already taken' % name)
@@ -167,7 +167,7 @@ def func(parser, options, args):
167167
raise CmdException('Need at least two patches')
168168
if options.name and not stack.patches.is_name_valid(options.name):
169169
raise CmdException('Patch name "%s" is invalid' % options.name)
170-
return _squash(
170+
return squash(
171171
stack,
172172
stack.repository.default_iw,
173173
options.name,

0 commit comments

Comments
 (0)