Skip to content

Commit 897f303

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 75688cd commit 897f303

File tree

5 files changed

+386
-30
lines changed

5 files changed

+386
-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: 174 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,21 @@
1+
from dataclasses import dataclass
2+
from enum import Enum
3+
import itertools
4+
import re
5+
6+
from stgit import utils
17
from stgit.argparse import opt
8+
from stgit.commands.squash import squash
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.utils import edit_string
1019

1120
__copyright__ = """
1221
Copyright (C) 2005, Catalin Marinas <[email protected]>
@@ -26,10 +35,10 @@
2635

2736
help = 'Move the stack base to another point in history'
2837
kind = 'stack'
29-
usage = ['[options] [--] <new-base-id>']
38+
usage = ['[options] [--] [new-base-id]']
3039
description = """
3140
Pop all patches from current stack, move the stack base to the given
32-
<new-base-id> and push the patches back.
41+
[new-base-id] and push the patches back.
3342
3443
If you experience merge conflicts, resolve the problem and continue
3544
the rebase by executing the following sequence:
@@ -45,6 +54,12 @@
4554

4655
args = ['commit']
4756
options = [
57+
opt(
58+
'-i',
59+
'--interactive',
60+
action='store_true',
61+
short='Open an interactive editor to manipulate patches',
62+
),
4863
opt(
4964
'-n',
5065
'--nopush',
@@ -61,11 +76,156 @@
6176

6277
directory = DirectoryGotoTopLevel()
6378

79+
INTERACTIVE_APPLY_LINE = '# --- APPLY_LINE ---'
80+
81+
INTERACTIVE_HELP_INSTRUCTIONS = """
82+
# Commands:
83+
# k, keep <patch> = do not modify this patch
84+
# s, squash <patch> = squash patch into the previous patch
85+
# d, delete <patch> = delete patch
86+
#
87+
# These lines can be re-ordered; they are executed from top to bottom.
88+
#
89+
# Patches above the APPLY_LINE are applied; other patches are kept unapplied.
90+
#
91+
# If you remove everything the rebase will be aborted.
92+
"""
93+
94+
95+
def __get_description(stack, patch):
96+
"""Extract and return a patch's short description"""
97+
cd = stack.patches[patch].data
98+
return cd.message_str.strip().split('\n', 1)[0].rstrip()
99+
100+
101+
class Action(Enum):
102+
# do nothing; a 'no-op'
103+
KEEP = 0
104+
# delete the patch
105+
DELETE = 1
106+
# squash the patch into the previous patch
107+
SQUASH = 2
108+
109+
110+
@dataclass
111+
class Instruction:
112+
patch_name: str
113+
action: Action
114+
apply: bool
115+
116+
117+
def __do_rebase_interactive(repository, previously_applied_patches, check_merged):
118+
"""Opens an interactive editor, generates instruction list, and executes instructions."""
119+
stack = repository.get_stack()
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+
if not instructions:
177+
raise CmdException('Nothing to do')
178+
179+
# execute all the non-squash instructions first
180+
for i in instructions:
181+
if i.apply:
182+
post_rebase(stack, {i.patch_name}, 'rebase', check_merged)
183+
if i.action == Action.DELETE:
184+
delete_patches(stack, stack.repository.default_iw, {i.patch_name})
185+
instructions.remove(i)
186+
187+
# execute squashes last because patch names may change during squashes. this
188+
# is a double loop so we can handle multiple chains of multiple squashes
189+
for index in itertools.count():
190+
if len(instructions) <= index:
191+
break # reached the end of the instruction list
192+
# determine how many of the next N instructions are squashes
193+
squashes_found = False
194+
for num_squashes in itertools.count(1):
195+
if len(instructions) <= index + num_squashes:
196+
break # reached the end of the instruction list
197+
if instructions[index + num_squashes].action == Action.SQUASH:
198+
squashes_found = True
199+
else:
200+
break # not a squash; chain is over
201+
# execute squash
202+
if squashes_found:
203+
squashes = instructions[index : index + num_squashes]
204+
squash(
205+
stack,
206+
stack.repository.default_iw,
207+
None,
208+
None,
209+
None,
210+
[p.patch_name for p in squashes],
211+
)
212+
# remove the squashed patches from the instruction set
213+
instructions = (
214+
instructions[: index + 1] + instructions[index + num_squashes :]
215+
)
216+
return utils.STGIT_SUCCESS
217+
64218

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

70230
repository = directory.repository
71231
stack = repository.get_stack()
@@ -74,14 +234,19 @@ def func(parser, options, args):
74234
if stack.protected:
75235
raise CmdException('This branch is protected. Rebase is not permitted')
76236

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

79242
applied = stack.patchorder.applied
80-
81243
retval = prepare_rebase(stack, 'rebase')
82244
if retval:
83245
return retval
84-
85246
rebase(stack, iw, target)
86-
if not options.nopush:
87-
return post_rebase(stack, applied, 'rebase', check_merged=options.merged)
247+
248+
if options.interactive:
249+
return __do_rebase_interactive(repository, applied, check_merged=options.merged)
250+
else:
251+
if not options.nopush:
252+
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
@@ -115,7 +115,7 @@ def _squash_patches(trans, patches, msg, save_template, no_verify=False):
115115
return cd
116116

117117

118-
def _squash(stack, iw, name, msg, save_template, patches, no_verify=False):
118+
def squash(stack, iw, name, msg, save_template, patches, no_verify=False):
119119
# If a name was supplied on the command line, make sure it's OK.
120120
if name and name not in patches and name in stack.patches:
121121
raise CmdException('Patch name "%s" already taken' % name)
@@ -171,7 +171,7 @@ def func(parser, options, args):
171171
raise CmdException('Need at least two patches')
172172
if options.name and not stack.patches.is_name_valid(options.name):
173173
raise CmdException('Patch name "%s" is invalid' % options.name)
174-
return _squash(
174+
return squash(
175175
stack,
176176
stack.repository.default_iw,
177177
options.name,

0 commit comments

Comments
 (0)