diff mbox series

[RFC] rebase: implement --rewind

Message ID 20230323162235.995645-1-oswald.buddenhagen@gmx.de (mailing list archive)
State New, archived
Headers show
Series [RFC] rebase: implement --rewind | expand

Commit Message

Oswald Buddenhagen March 23, 2023, 4:22 p.m. UTC
This is fundamentally --edit-todo, except that we first prepend the
already applied commits and reset back to `onto`. This is useful when
one finds that a prior change needs (further) modifications.

This patch implements "flat" rewind, that is, once the todo edit has
been committed, one can abort only the complete rebase. The pre-rewind
position is marked with a `break` command (these pile up when rewinding
multiple times; the user is expected to clean them up as necessary).

An alternative to that would be "nested" rewind, where one can return to
the pre-rewind state even after committing the todo edit. However, this:
- would add somewhat significant complexity due to having to maintain a
  stack of todos and HEADs
- would be mildly confusing to use due to needing to track the state of
  the stack. One could simplify this somewhat by hiding the rest of the
  previous todo before nesting, but this would be somewhat limiting in
  turn (one might want to defer a factored out hunk, and stashing it is
  not necessarily the most elegant way to do it).
- would be of somewhat limited usefulness, speaking from experience

This patch leaves transitive resolution of rewritten-list to the
consumer. This is probably a bad idea.
Somewhat related to that, --update-refs isn't properly handled yet.

Reference: <YhPiqlM81XCjNWpk@ugly>
Signed-off-by: Oswald Buddenhagen <oswald.buddenhagen@gmx.de>
---
 Documentation/git-rebase.txt  |  14 ++++-
 builtin/rebase.c              |  98 ++++++++++++++++++++++++++++--
 rebase-interactive.c          |  34 ++++++++++-
 rebase-interactive.h          |   2 +
 sequencer.c                   |  37 +++++++++---
 sequencer.h                   |   3 +
 t/t3404-rebase-interactive.sh | 111 ++++++++++++++++++++++++++++++++++
 7 files changed, 281 insertions(+), 18 deletions(-)

Comments

Johannes Schindelin March 28, 2023, 2:53 p.m. UTC | #1
Hi Oswald,

On Thu, 23 Mar 2023, Oswald Buddenhagen wrote:

> This is fundamentally --edit-todo, except that we first prepend the
> already applied commits and reset back to `onto`. This is useful when
> one finds that a prior change needs (further) modifications.
>
> This patch implements "flat" rewind, that is, once the todo edit has
> been committed, one can abort only the complete rebase. The pre-rewind
> position is marked with a `break` command (these pile up when rewinding
> multiple times; the user is expected to clean them up as necessary).
>
> An alternative to that would be "nested" rewind, where one can return to
> the pre-rewind state even after committing the todo edit. However, this:
> - would add somewhat significant complexity due to having to maintain a
>   stack of todos and HEADs
> - would be mildly confusing to use due to needing to track the state of
>   the stack. One could simplify this somewhat by hiding the rest of the
>   previous todo before nesting, but this would be somewhat limiting in
>   turn (one might want to defer a factored out hunk, and stashing it is
>   not necessarily the most elegant way to do it).
> - would be of somewhat limited usefulness, speaking from experience
>
> This patch leaves transitive resolution of rewritten-list to the
> consumer. This is probably a bad idea.
> Somewhat related to that, --update-refs isn't properly handled yet.

This is an interesting idea but I do not think that the concept in its
current form mixes well with being in the middle of a `--rebase-merges`
run. Which is what I would want to see supported because I find myself
wishing for _something_ like this [*1*].

On the other hand, it might often be good enough to redo only the commits
between `onto` and `HEAD`, not the complete rebase script that's now in
`done`. But then it does not strike me so much as "rewinding" but as
"nesting.

In other words, I would then prefer to see support for `git rebase -i
--nested` be added. I wrote up my thoughts about this in
https://github.com/gitgitgadget/git/issues/211 and was tempted several
times to start implementing it already, only for other, more pressing
things to come up and take my attention.

What do you think, would you be amenable to combine efforts?

Ciao,
Johannes

Footnote *1*: I usually find myself adding a temporary worktree with a
detached `HEAD`, performing the nested rebase, and then resetting the
original worktree to the output of `rev-parse HEAD` in the temporary
worktree. Cumbersome, yes, and it works, but yes, I dearly want something
that works without requiring _me_ to keep tabs on the state.
Oswald Buddenhagen March 28, 2023, 4:11 p.m. UTC | #2
On Tue, Mar 28, 2023 at 04:53:52PM +0200, Johannes Schindelin wrote:
>I do not think that the concept in its
>current form mixes well with being in the middle of a `--rebase-merges`
>run.
>
fundamentally, it shouldn't pose a problem: the already done part leads 
up to a single commit, from which a complete todo with merges up to that 
point can be built again, while the remainder of the pre-existing todo 
should be unfazed by the fact that you're repeatedly messing with 
whatever branch you stopped in.
i *think* i even tried a few simple cases and found the result adequate, 
but i don't remember for sure (it's been a few months since i authored 
the patch).
just give it a shot.

>On the other hand, it might often be good enough to redo only the 
>commits
>between `onto` and `HEAD`, not the complete rebase script that's now in
>`done`. But then it does not strike me so much as "rewinding" but as
>"nesting.
>
according to the terminology i'm using, this still qualifies as a flat 
rewind, only that it limits itself to --first-parent. we'll see whether 
this turns out to be a necessary simplification, but i don't think so.

true nesting would mean that the rewind itself can be aborted, in case 
you change your mind back. adding that as an option on top of what i'm 
doing isn't a hard problem _per se_. you would need to figure out the 
challenges from my OP, though.
also note the reference in the OP; we discussed this here a while ago 
already.
Johannes Schindelin April 5, 2023, 12:07 p.m. UTC | #3
Hi Oswald,

please do reply-to-all on this list.

On Tue, 28 Mar 2023, Oswald Buddenhagen wrote:

> On Tue, Mar 28, 2023 at 04:53:52PM +0200, Johannes Schindelin wrote:
> >I do not think that the concept in its
> >current form mixes well with being in the middle of a `--rebase-merges`
> >run.
>
> fundamentally, it shouldn't pose a problem: the already done part leads up to
> a single commit, from which a complete todo with merges up to that point can
> be built again, while the remainder of the pre-existing todo should be unfazed
> by the fact that you're repeatedly messing with whatever branch you stopped
> in.

I guess the most important question is: What problem is the proposed
`--rewind` option supposed to solve?

If the idea is to let the user re-start the rebase (for whatever reason),
throwing away the current state, then the proposed code really does not
handle the `--rebase-merges` case at all. Instead, it would implicitly
restart the rebase with `--no-rebase-merges`, i.e. the opposite of what
the user asked for.

But a more important concern is: Is this `--rewind` idea even a good one?
This question brings me back to the initial question: What problem do we
try to solve here? (This is a question that try as I might, I cannot see
answered in the proposed commit message.)

Since I do not want to speculate about your motivation, let me explain the
challenges I would like to see addressed with those rewound-or-nested
rebases.

I frequently find myself in _large_ rebases (we're talking about several
thousand commits, with some 100-200 merge commits), where I notice in the
middle that I should have resolved a previous merge conflict in a
different way.

Do I want to start over and redo the whole rebase? Sometimes I do, and
`git rebase --abort` and the Bash history (Ctrl+R -i will get me back to
the start of the interactive rebase) are my friend. No `--rewind`
required. (Which makes me wonder why that same strategy is not good enough
for your scenarios, too.)

However, that's what I need only in a few, rare instances.

What I need much, much, much more often is a way to redo only _part_ of
the rebase. Like, the last 3 commits. And not from scratch, oh no! I do
not want the original commits to be cherry-picked, but the ones that were
already rebased.

In other words, I need a nested rebase.

Now, why do I keep bringing up this idea of a nested rebase, when such a
nested rebase would not be able to perform a rewind as you asked?

The reason is that I am still very much unconvinced that `--rewind` can do
anything that `git rebase --abort` and starting over cannot do. So: no
patches required, right?

However, the use case that _immediately_ comes to mind when you talk about
these rewinds is when a part of a rebase needs to be redone, in the middle
of said rebase. And that _does_ require a nested rebase, and the
`--rewind` would in most cases only throw away too much work.

Ciao,
Johannes

P.S.: Yes, yes, I know, a nested rebase can be simulated via

	git worktree add --detach /tmp/throw-away &&
	git -C /tmp/throw-away rebase -i HEAD~3 &&
	git reset --hard $(git -C /tmp/throw-away rev-parse HEAD) &&
	git worktree remove /tmp/throw-away

but that is of course not only inconvenient, but leaves too much
book-keeping and safe-guarding up to the human user, e.g. to make sure
that the `git reset --hard` does not overwrite uncommitted changes/files.

FWIW I simulate nested rebases in the illustrated way _a lot_.
Oswald Buddenhagen April 5, 2023, 3:15 p.m. UTC | #4
On Wed, Apr 05, 2023 at 02:07:29PM +0200, Johannes Schindelin wrote:
>This question brings me back to the initial question: What problem do 
>we try to solve here? (This is a question that try as I might, I cannot 
>see answered in the proposed commit message.)
>
and i, try as i might, don't understand what you're not understanding 
...

>[...] In other words, I need a nested rebase.
>
that's just *your* private terminology. i don't apply the term "nested" 
here, because for me that implies the possibility to "unnest", which my 
patch doesn't implement. instead, it just continues past the point where 
the rewind was initiated. it's the difference between a loop and 
recursion.
but outside this difference in terminology, for all i can tell, my patch 
implements *exactly* what you're asking for, and i don't understand why 
that's not obvious to you, given how well you understand the problem 
space yourself.
please describe what you want with _a few_ words and without introducing 
any new terminology first, i.e., something you'd actually want to see in 
the feature's summary documentation. that should illuminate what 
keywords you find critical.

i just gave rewinding rebasing merges a shot, and it didn't work for the 
simple reason that --rebase-merges is not saved in the state 
(understandably, because that was unnecessary so far) and the 
combination of --rewind --rebase-merges is being rejected. i'll need to 
fix that.

then there is the problem that --rebase-merges only redoes merges rather 
than replaying them. but it seems that the simple case with unmodified 
parents actually does get replayed (or rather, skipped over, just 
incredibly slowly), so rewinding to just the last merge would work fine.  
other than that, i'm declaring the matter out of scope and deferring to 
your "replaying evil merges" sub-thread.
Phillip Wood April 6, 2023, 10:01 a.m. UTC | #5
On 05/04/2023 13:07, Johannes Schindelin wrote:
> Hi Oswald,
> 
> please do reply-to-all on this list.
> 
> On Tue, 28 Mar 2023, Oswald Buddenhagen wrote:
> 
>> On Tue, Mar 28, 2023 at 04:53:52PM +0200, Johannes Schindelin wrote:
>>> I do not think that the concept in its
>>> current form mixes well with being in the middle of a `--rebase-merges`
>>> run.

That definitely needs to be addressed, I'd be happy to start with an 
implementation that only rewinds linear history but it must error out if 
it encounters a merge and --rebase-merges was given. I'd also be very 
happy if we could rewind across merges by updating existing labels in 
the new todo list.

> [...] 
> What I need much, much, much more often is a way to redo only _part_ of
> the rebase. Like, the last 3 commits. And not from scratch, oh no! I do
> not want the original commits to be cherry-picked, but the ones that were
> already rebased.

That's what I want most often as well. Oswald's --rewind always rewinds 
to $onto but I think it does use the rebased commits in the new todo 
list. It looks like the new todo list will contain the commits 
$onto..HEAD plus the old todo list

> In other words, I need a nested rebase.

I can see the benefit in being able to checkpoint while rebasing but I'm 
not sure that needs to be tied to rewinding. For example if I'm making a 
change I'm not sure about I'd like to be able to set a checkpoint before 
that change so I can rewind to the previous state. I'd be happy to see 
checkpointing and rewinding added separately.

Best Wishes

Phillip

> Now, why do I keep bringing up this idea of a nested rebase, when such a
> nested rebase would not be able to perform a rewind as you asked?
> 
> The reason is that I am still very much unconvinced that `--rewind` can do
> anything that `git rebase --abort` and starting over cannot do. So: no
> patches required, right?
> 
> However, the use case that _immediately_ comes to mind when you talk about
> these rewinds is when a part of a rebase needs to be redone, in the middle
> of said rebase. And that _does_ require a nested rebase, and the
> `--rewind` would in most cases only throw away too much work.
> 
> Ciao,
> Johannes
> 
> P.S.: Yes, yes, I know, a nested rebase can be simulated via
> 
> 	git worktree add --detach /tmp/throw-away &&
> 	git -C /tmp/throw-away rebase -i HEAD~3 &&
> 	git reset --hard $(git -C /tmp/throw-away rev-parse HEAD) &&
> 	git worktree remove /tmp/throw-away
> 
> but that is of course not only inconvenient, but leaves too much
> book-keeping and safe-guarding up to the human user, e.g. to make sure
> that the `git reset --hard` does not overwrite uncommitted changes/files.
> 
> FWIW I simulate nested rebases in the illustrated way _a lot_.
Ævar Arnfjörð Bjarmason April 6, 2023, 10:45 a.m. UTC | #6
On Wed, Apr 05 2023, Oswald Buddenhagen wrote:

> On Wed, Apr 05, 2023 at 02:07:29PM +0200, Johannes Schindelin wrote:
>> This question brings me back to the initial question: What problem
>> do we try to solve here? (This is a question that try as I might, I
>> cannot see answered in the proposed commit message.)
>>
> and i, try as i might, don't understand what you're not understanding
> ...
>
>>[...] In other words, I need a nested rebase.
>>
> that's just *your* private terminology. i don't apply the term
> "nested" here, because for me that implies the possibility to
> "unnest", which my patch doesn't implement. instead, it just continues
> past the point where the rewind was initiated. it's the difference
> between a loop and recursion.
> but outside this difference in terminology, for all i can tell, my
> patch implements *exactly* what you're asking for, and i don't
> understand why that's not obvious to you, given how well you
> understand the problem space yourself.
> please describe what you want with _a few_ words and without
> introducing any new terminology first, i.e., something you'd actually
> want to see in the feature's summary documentation. that should
> illuminate what keywords you find critical.
>
> i just gave rewinding rebasing merges a shot, and it didn't work for
> the simple reason that --rebase-merges is not saved in the state
> (understandably, because that was unnecessary so far) and the
> combination of --rewind --rebase-merges is being rejected. i'll need
> to fix that.
>
> then there is the problem that --rebase-merges only redoes merges
> rather than replaying them. but it seems that the simple case with
> unmodified parents actually does get replayed (or rather, skipped
> over, just incredibly slowly), so rewinding to just the last merge
> would work fine.  other than that, i'm declaring the matter out of
> scope and deferring to your "replaying evil merges" sub-thread.

Not Johannes, but I'd also like to have "nested", but maybe your feature
would also provide that. I haven't had time to test it, sorry.

But isn't the difference noted in this aspect of your commit message:
"where one can return to the pre-rewind state even after committing the
todo edit".

My most common use-case for "nested" is certainly less complex that
Johannes's, and is the following:

 * I've got e.g. a 10 patch series

 * I start rebasing that on "master", solve conflicts with "1..4", and
   am now on a conflict on 5/10.

 * It now becomes obvious to me that the even larger conflict I'm about
   to have on 6/10 would be better handled if I went back to 2/10 or
   whatever, did a change I could do here in 5/10 differently, and then
   proceeded.

I.e. when I'm at 5/10 I'd conceptually like to do another "git rebase -i
HEAD~5" or whatever, use the *already rewritten* commits (otherwise I'd
just abort and restast), re-arrange/rewrite them, and when I'm done
return to 5/10.

Then do another "continue".

From a UX perspective I think just as our $PS1 integration can be made
to show "5/10" it would be ideal if in this case we could show
e.g. "5/10 -> 1/5" or whatever. I.e. I'm in a nested rebase of 1/5,
which started from that 5/10".

Right now I do this sort of thing manually, i.e. note the SHA-1's I've
got so far, --abort at 5/10, then start a rebase for all 10 again, but
manually replace the SHA-1's for 1-5 with the ones I had already.

Which, I suppose I could also do the other way around, i.e. at 5/10 I'd
--edit-todo, wipe away 6/10, "finish" my rebase, then use "git rebase
--onto" later when I'm done to transplant the remaining 6-10/10 on the
1-5/5 I'm now happy with.

But here's the important bit: Sometimes I'm just wrong about my re-edit
to 2/10 being the right thing, and it would actually just make things
worse, as I might discover in my "nested" rebase once I'm at 4/5 or
whatever.

So being able to do an "--abort" ot that point to go back to the
"un-nested" 5/10 (*not* "original" 5/10) and proceed from there would be
nice.

But I think what you've implemented doesn't do that at all, or am I
misunderstanding you?

I think a relatively simple hack to "restart" might still be very nice,
just clarifying.
Oswald Buddenhagen April 6, 2023, 2:49 p.m. UTC | #7
On Thu, Apr 06, 2023 at 12:45:02PM +0200, Ævar Arnfjörð Bjarmason wrote:
>My most common use-case for "nested" is certainly less complex that
>Johannes's, and is the following:
>
> * I've got e.g. a 10 patch series
>
> * I start rebasing that on "master", solve conflicts with "1..4", and
>   am now on a conflict on 5/10.
>
> * It now becomes obvious to me that the even larger conflict I'm about
>   to have on 6/10 would be better handled if I went back to 2/10 or
>   whatever, did a change I could do here in 5/10 differently, and then
>   proceeded.
>
>I.e. when I'm at 5/10 I'd conceptually like to do another "git rebase 
>-i
>HEAD~5" or whatever, use the *already rewritten* commits (otherwise I'd
>just abort and restast), re-arrange/rewrite them, and when I'm done
>return to 5/10.
>
yes, this patch addresses this use case - mostly.

i'm generally dealing with an even more benign case, because i'm 
"rebasing" with --keep-base most of the time (and i have the thing 
aliased to 'reshape' - maybe something for upstream?).

the case of rewinding from a conflicted state currently needs manual 
handling. i suppose i should detect the state, re-insert the pick, and 
reset hard out of it, as if --skip was used. the implicit 
destructiveness feels wrong, though. maybe require --force?

>But here's the important bit: Sometimes I'm just wrong about my re-edit
>to 2/10 being the right thing, and it would actually just make things
>worse, as I might discover in my "nested" rebase once I'm at 4/5 or
>whatever.
>
>So being able to do an "--abort" ot that point to go back to the
>"un-nested" 5/10 (*not* "original" 5/10) and proceed from there would be
>nice.
>
yeah, i'm experiencing that sometimes, but not often enough to bother 
automating it. manual recovery by hand-editing the todo after rewinding 
again did the trick so far.

>From a UX perspective I think just as our $PS1 integration can be made
>to show "5/10" it would be ideal if in this case we could show
>e.g. "5/10 -> 1/5" or whatever. I.e. I'm in a nested rebase of 1/5,
>which started from that 5/10".
>
hmm, i think you just pointed out johannes' hangup to me. ^^

you both are assuming a limited rewind, where you explicitly specify the 
affected range, and the todo list editor presents only that. you're 
deriving the term "nested" from the fact that it's an isolated subset of 
the rewritten commits.

however, i see these problems with that aproach:
- as mentioned in the OP, i might want to move hunks out of the nested 
   range. i could stash them, but then i'm dealing with two methods of 
   organizing the history, which gets really messy
- it gets even trickier if i want to move commits *into* the nested 
   range - i'd have to manually insert a pick, and then deal with the 
   possible conflict after unnesting
- who says that the nesting point should be the last chance to change my 
   mind? suppose i stop at 10, get the idea to re-edit 5, but after 
   reaching 15 i notice that re-editing 5 (and thus probably also 10)  
   was a terrible idea, so i want to go back to pre-nest 10

now suppose my approach, where the rebase is rewound right to `onto`, 
and the whole remaining todo is left in place. the nested base is 
implicitly determined by the first modified line of the rewound todo, so 
there is no harm in rewinding the whole rebase (*). and the rebase can 
just continue past the rewind point without anything special happening.  

if we want to be able to undo the rewind, we push HEAD and the todo list 
onto a stack. as phillip said, that's basically just a checkpoint, which 
happens to be automatically created when we are rewinding. that could be 
presented at the prompt as "REBASE 5/10 [1]" to signify the number of 
available checkpoints (and you'd access them with 'git rebase --restore 
[<id>]', quite similarly to stashes).

of course it gets really "interesting" when you want to go back to a 
checkpoint, but also want to salvage some of the rewritten commits. then 
you'll have to manually pick commits from the reflog, etc., but i don't 
see how one could possibly get around the complexity (we could present a 
combined todo file where alternative versions of commits are shown in 
comments, but that's quite some effort for only a slight improvement).

(*) actually, there is:
- firstly, having the entire todo in front of you can be rather annoying 
   when it's more than two dozen commits long and the part you want to 
   edit isn't near the beginning.
- secondly, skipping over merges doesn't appear to be a thing, so 
   johannes' use case would be *insanely* slow. but that's "only" an 
   implementation issue.

given these problems, i can see that it would make sense to accept an 
optional argument that limits the depth of the rewind (without impacting 
the overall approach).

thanks!
Felipe Contreras April 7, 2023, 12:21 a.m. UTC | #8
On Thu, Apr 6, 2023 at 7:03 AM Ævar Arnfjörð Bjarmason <avarab@gmail.com> wrote:
> On Wed, Apr 05 2023, Oswald Buddenhagen wrote:

> Right now I do this sort of thing manually, i.e. note the SHA-1's I've
> got so far, --abort at 5/10, then start a rebase for all 10 again, but
> manually replace the SHA-1's for 1-5 with the ones I had already.

Couldn't this be considered a rerebase?

This is what I do most of the time, except I don't even bother saving
and replacing the SHA-1s, rerere reapplies my fixes so it's
straightforward to reach the desired state.

> Which, I suppose I could also do the other way around, i.e. at 5/10 I'd
> --edit-todo, wipe away 6/10, "finish" my rebase, then use "git rebase
> --onto" later when I'm done to transplant the remaining 6-10/10 on the
> 1-5/5 I'm now happy with.

With this approach the reflog wouldn't be an accurate representation
of what happened.

Most of the times I do a rebase I want to see the difference with the
previous state of the branch, so I do `git diff @{1}`, but this won't
work with this frankensteinian rebase which in true is comprised of
multiple subrebases.

> But here's the important bit: Sometimes I'm just wrong about my re-edit
> to 2/10 being the right thing, and it would actually just make things
> worse, as I might discover in my "nested" rebase once I'm at 4/5 or
> whatever.
>
> So being able to do an "--abort" ot that point to go back to the
> "un-nested" 5/10 (*not* "original" 5/10) and proceed from there would be
> nice.

Yes, this is something I often desire.


But I feel you guys are overcomplicating the problem.

Imagine there was a rebase log for each branch, then `git rebase`
could use that information to redo a previous rebase, even if that
rebase was aborted. To restart your current rebase you could do `git
rebase -i --redo 1` (1 being the previous one). If in the middle of
that you decide actually your original approach was better, you just
freely abort, and do `git rebase -i --redo 2`.

Wouldn't that solve all the problems?

The complication comes in trying to do that without the concept of
rebase history.

Cheers.
Oswald Buddenhagen April 7, 2023, 7 a.m. UTC | #9
On Thu, Apr 06, 2023 at 07:21:39PM -0500, Felipe Contreras wrote:
>Imagine there was a rebase log for each branch, then `git rebase`
>could use that information to redo a previous rebase, even if that
>rebase was aborted. To restart your current rebase you could do `git
>rebase -i --redo 1` (1 being the previous one). If in the middle of
>that you decide actually your original approach was better, you just
>freely abort, and do `git rebase -i --redo 2`.
>
what exactly would you save to that log?
what comes to my mind is the todo file produced by my --rewind before 
the user edits it: the already rewritten commits (which can of course be 
saved as a single ref), and the remaining todo.
that would make it very much the same thing as the checkpoints phillip 
postulated and i expanded upon.

one difference to what i envisaged would be that one could easily resume 
a rebase one erroneously discarded entirely.

>Wouldn't that solve all the problems?
>
it would, but not necessarily optimally.

consider that after the initial implementation phase, my working branch 
is most of the time inside a 'reshape' (rebase -i --keep-base), and 
since i wrote the initial version of rewind, i initiate new reshapes 
much less often. i basically move freely between the commits in the 
branch.
inserting an additional step of aborting prior to redoing feels just 
clumsy.
at this point i'm actually thinking in the opposite direction: introduce 
commands that let me move by a few commits without even opening the todo 
editor (where have i seen that already? jj?).

the second aspect is performance/resource usage.
the intermediate abort would potentially touch a lot of files each time.  
that costs ssd life and often unneeded recompiles.
and given johannes' use case with *many* merges, rebasing from scratch 
would waste *quite* some time. as i pointed out in the other mail, my 
approach currently suffers from that as well, but it would be rather 
easy to sidestep it. your approach otoh would definitely need a 
fundamental improvement to the skipping algo (*).

(*) this of course sounds like a good idea regardless, but it's not 
necessarily wise to bet on it. i think the problem here is that redoing 
merges is *expected* to be "lossy". if they were marked for replay as 
proposed in https://github.com/gitgitgadget/git/pull/1491 , one could 
also just skip over them.
Phillip Wood April 11, 2023, 10:06 a.m. UTC | #10
On 07/04/2023 08:00, Oswald Buddenhagen wrote:

> at this point i'm actually thinking in the opposite direction: introduce 
> commands that let me move by a few commits without even opening the todo 
> editor (where have i seen that already? jj?).

When I'm working on a patch series I use this approach a lot with a "git 
rewrite" script I have. To amend a commit I run "git rewrite amend 
<commit>" and it will start a rebase or rewind the current one. It will 
also take a file, line number pair and use "git diff" to map that line 
onto HEAD and then "git blame" to work out which commit to amend so you 
can run it from your editor and say "amend the commit that added this 
line" which is a great time saver. I'd love to a command like that 
upstream in git but I don't think it covers all the cases that "rebase 
--rewind" does such as dscho rebasing git-for-windows.

Best Wishes

Phillip
diff mbox series

Patch

diff --git a/Documentation/git-rebase.txt b/Documentation/git-rebase.txt
index 9a295bcee4..f736131a6c 100644
--- a/Documentation/git-rebase.txt
+++ b/Documentation/git-rebase.txt
@@ -12,7 +12,8 @@  SYNOPSIS
 	[--onto <newbase> | --keep-base] [<upstream> [<branch>]]
 'git rebase' [-i | --interactive] [<options>] [--exec <cmd>] [--onto <newbase>]
 	--root [<branch>]
-'git rebase' (--continue | --skip | --abort | --quit | --edit-todo | --show-current-patch)
+'git rebase' (--continue | --skip | --abort | --quit | --edit-todo | --rewind |
+	--show-current-patch)
 
 DESCRIPTION
 -----------
@@ -215,7 +216,8 @@  The options in this section cannot be used with any other option,
 including not with each other:
 
 --continue::
-	Restart the rebasing process after having resolved a merge conflict.
+	Restart the rebasing process after an interruption, e.g. having
+	resolved a merge conflict.
 
 --skip::
 	Restart the rebasing process by skipping the current patch.
@@ -236,6 +238,10 @@  including not with each other:
 --edit-todo::
 	Edit the todo list during an interactive rebase.
 
+--rewind::
+	Edit the todo list during an interactive rebase, but first
+	prepend the commits on top of the new base and reset to it.
+
 --show-current-patch::
 	Show the current patch in an interactive rebase or when rebase
 	is stopped because of conflicts. This is the equivalent of
@@ -975,6 +981,10 @@  pick f4593f9 four
 exec make test
 --------------------
 
+If during editing a commit you notice that an ancestor commit should be
+actually edited first, you may use `git rebase --rewind` to restart the
+interactive rebase without starting from scratch.
+
 SPLITTING COMMITS
 -----------------
 
diff --git a/builtin/rebase.c b/builtin/rebase.c
index 61e5363ac7..3a14ac1a4f 100644
--- a/builtin/rebase.c
+++ b/builtin/rebase.c
@@ -36,7 +36,8 @@  static char const * const builtin_rebase_usage[] = {
 		"[--onto <newbase> | --keep-base] [<upstream> [<branch>]]"),
 	N_("git rebase [-i] [options] [--exec <cmd>] [--onto <newbase>] "
 		"--root [<branch>]"),
-	"git rebase --continue | --abort | --skip | --edit-todo",
+	"git rebase --continue | --abort | --quit | --skip | --edit-todo | "
+		"--rewind",
 	NULL
 };
 
@@ -65,6 +66,8 @@  static const char *action_names[] = {
 	"abort",
 	"quit",
 	"edit_todo",
+	"rewind",
+	"resume_rewind",
 	"show_current_patch"
 };
 
@@ -183,17 +186,21 @@  static int edit_todo_file(unsigned flags)
 	const char *todo_file = rebase_path_todo();
 	struct todo_list todo_list = TODO_LIST_INIT,
 		new_todo = TODO_LIST_INIT;
+	enum rebase_action action = file_exists(rebase_path_todo_orig()) ?
+				ACTION_RESUME_REWIND : ACTION_EDIT_TODO;
 	int res = 0;
 
 	if (strbuf_read_file(&todo_list.buf, todo_file, 0) < 0)
 		return error_errno(_("could not read '%s'."), todo_file);
 
 	strbuf_stripspace(&todo_list.buf, 1);
 	res = edit_todo_list(the_repository, &todo_list, &new_todo, NULL, NULL, flags,
-			     ACTION_EDIT_TODO);
-	if (!res && todo_list_write_to_file(the_repository, &new_todo, todo_file,
-					    NULL, NULL, -1, flags & ~(TODO_LIST_SHORTEN_IDS),
-					    ACTION_EDIT_TODO))
+			     action);
+	if (res == EDIT_TODO_ABORT)
+		res = error(_("rewind aborted; state restored"));
+	else if (!res && todo_list_write_to_file(the_repository, &new_todo, todo_file,
+						 NULL, NULL, -1,
+						 flags & ~(TODO_LIST_SHORTEN_IDS), action))
 		res = error_errno(_("could not write '%s'"), todo_file);
 
 	todo_list_release(&todo_list);
@@ -301,6 +308,64 @@  static int do_interactive_rebase(struct rebase_options *opts, unsigned flags)
 	return ret;
 }
 
+static int rewind_todo_file(struct rebase_options *opts,
+			    unsigned flags)
+{
+	int ret;
+	char *revisions;
+	const char *todo_file = rebase_path_todo();
+	struct strvec make_script_args = STRVEC_INIT;
+	struct todo_list todo_list = TODO_LIST_INIT;
+	struct replay_opts replay = get_replay_opts(opts);
+	struct string_list commands = STRING_LIST_INIT_DUP;
+
+	require_clean_work_tree(the_repository,
+		N_("rewind rebase"),
+		_("Please commit or stash them."), 1, 0);
+
+	if (file_exists(rebase_path_todo_orig()))
+		return error(_("you are already rewinding a rebase.\n"
+			       "Use rebase --edit-todo to continue."));
+
+	revisions = xstrfmt("%s..HEAD", oid_to_hex(&opts->onto->object.oid));
+	strvec_pushl(&make_script_args, "", revisions, NULL);
+	free(revisions);
+
+	ret = sequencer_make_script(the_repository, &todo_list.buf,
+				    make_script_args.nr, make_script_args.v,
+				    flags);
+	strvec_clear(&make_script_args);
+
+	if (ret)
+		error(_("could not generate todo list"));
+	else {
+		if (flags & TODO_LIST_ABBREVIATE_CMDS)
+			strbuf_addstr(&todo_list.buf, "b\n\n");
+		else
+			strbuf_addstr(&todo_list.buf, "break\n\n");
+
+		if (strbuf_read_file(&todo_list.buf, todo_file, 0) < 0) {
+			strbuf_release(&todo_list.buf);
+			return error_errno(_("could not read '%s'."), todo_file);
+		}
+
+		discard_index(&the_index);
+		if (todo_list_parse_insn_buffer(the_repository, todo_list.buf.buf,
+						&todo_list))
+			BUG("unusable todo list");
+
+		ret = complete_action(the_repository, &replay, flags,
+			NULL, opts->onto_name, &opts->onto->object.oid,
+			&opts->orig_head->object.oid, &opts->exec,
+			opts->autosquash, opts->update_refs, &todo_list,
+			opts->action);
+	}
+
+	todo_list_release(&todo_list);
+
+	return ret;
+}
+
 static int run_sequencer_rebase(struct rebase_options *opts)
 {
 	unsigned flags = 0;
@@ -342,6 +407,9 @@  static int run_sequencer_rebase(struct rebase_options *opts)
 	case ACTION_EDIT_TODO:
 		ret = edit_todo_file(flags);
 		break;
+	case ACTION_REWIND:
+		ret = rewind_todo_file(opts, flags);
+		break;
 	case ACTION_SHOW_CURRENT_PATCH: {
 		struct child_process cmd = CHILD_PROCESS_INIT;
 
@@ -1088,6 +1156,8 @@  int cmd_rebase(int argc, const char **argv, const char *prefix)
 			    N_("abort but keep HEAD where it is"), ACTION_QUIT),
 		OPT_CMDMODE(0, "edit-todo", &options.action, N_("edit the todo list "
 			    "during an interactive rebase"), ACTION_EDIT_TODO),
+		OPT_CMDMODE(0, "rewind", &options.action, N_("rewind an interactive "
+			    "rebase"), ACTION_REWIND),
 		OPT_CMDMODE(0, "show-current-patch", &options.action,
 			    N_("show the patch file being applied or merged"),
 			    ACTION_SHOW_CURRENT_PATCH),
@@ -1235,6 +1305,9 @@  int cmd_rebase(int argc, const char **argv, const char *prefix)
 	if (options.action == ACTION_EDIT_TODO && !is_merge(&options))
 		die(_("The --edit-todo action can only be used during "
 		      "interactive rebase."));
+	else if (options.action == ACTION_REWIND && !is_merge(&options))
+		die(_("The --rewind action can only be used during "
+		      "interactive rebase."));
 
 	if (trace2_is_enabled()) {
 		if (is_merge(&options))
@@ -1339,17 +1412,21 @@  int cmd_rebase(int argc, const char **argv, const char *prefix)
 	case ACTION_EDIT_TODO:
 		options.dont_finish_rebase = 1;
 		goto run_rebase;
+	case ACTION_REWIND:
+		if (read_basic_state(&options))
+			exit(1);
+		break;
 	case ACTION_SHOW_CURRENT_PATCH:
 		options.dont_finish_rebase = 1;
 		goto run_rebase;
 	case ACTION_NONE:
 		break;
 	default:
 		BUG("action: %d", options.action);
 	}
 
 	/* Make sure no rebase is in progress */
-	if (in_progress) {
+	if (in_progress && options.action != ACTION_REWIND) {
 		const char *last_slash = strrchr(options.state_dir, '/');
 		const char *state_dir_base =
 			last_slash ? last_slash + 1 : options.state_dir;
@@ -1570,6 +1647,15 @@  int cmd_rebase(int argc, const char **argv, const char *prefix)
 		options.flags |= REBASE_FORCE;
 	}
 
+	// We branch off after handling any option that could usefully
+	// affect the re-creation of the todo list.
+	// The omission of --onto from that is debatable.
+	// Options that will be overwritten by read_basic_state() are
+	// meaningless, so we can branch out before processing these;
+	// though arguably, it should be possible to change some of them.
+	if (options.action == ACTION_REWIND)
+		goto run_rebase;
+
 	if (!options.root) {
 		if (argc < 1) {
 			struct branch *branch;
diff --git a/rebase-interactive.c b/rebase-interactive.c
index a3d8925b06..d72ac7b8d1 100644
--- a/rebase-interactive.c
+++ b/rebase-interactive.c
@@ -87,6 +87,16 @@  void append_todo_help(int command_count, enum rebase_action action,
 			"of an ongoing interactive rebase.\n"
 			"To continue rebase after editing, run:\n"
 			"    git rebase --continue\n\n");
+	else if (action == ACTION_REWIND)
+		msg = _("\nYou are rewinding "
+			"an ongoing interactive rebase.\n"
+			"If you remove everything, "
+			"the todo file will be left unchanged.\n\n");
+	else if (action == ACTION_RESUME_REWIND)
+		msg = _("\nYou are correcting the rewind of "
+			"an ongoing interactive rebase.\n"
+			"If you remove everything, "
+			"the todo file will be restored.\n\n");
 	else
 		msg = _("\nHowever, if you remove everything, "
 			"the rebase will be aborted.\n\n");
@@ -101,9 +111,18 @@  enum edit_todo_result edit_todo_list(
 		   enum rebase_action action)
 {
 	const char *todo_file = rebase_path_todo(),
-		*todo_backup = rebase_path_todo_backup();
+		*todo_backup = rebase_path_todo_backup(),
+		*todo_file_orig = rebase_path_todo_orig(),
+		*done_file = rebase_path_done(),
+		*done_file_orig = rebase_path_done_orig();
 	int incorrect = 0;
 
+	if (action == ACTION_REWIND) {
+		if (rename(todo_file, todo_file_orig) ||
+		    rename(done_file, done_file_orig))
+			return error_errno(_("cannot displace todo file"));
+	}
+
 	/* If the user is editing the todo list, we first try to parse
 	 * it.  If there is an error, we do not return, because the user
 	 * might want to fix it in the first place. */
@@ -127,8 +146,14 @@  enum edit_todo_result edit_todo_list(
 		return EDIT_TODO_FAILED;
 
 	strbuf_stripspace(&new_todo->buf, 1);
-	if (action != ACTION_EDIT_TODO && new_todo->buf.len == 0)
+	if (action != ACTION_EDIT_TODO && new_todo->buf.len == 0) {
+		if (action == ACTION_REWIND || action == ACTION_RESUME_REWIND) {
+			if (rename(todo_file_orig, todo_file) ||
+			    rename(done_file_orig, done_file))
+				return error_errno(_("cannot restore todo file"));
+		}
 		return EDIT_TODO_ABORT;
+	}
 
 	if (todo_list_parse_insn_buffer(r, new_todo->buf.buf, new_todo)) {
 		fprintf(stderr, _(edit_todo_list_advice));
@@ -148,6 +173,11 @@  enum edit_todo_result edit_todo_list(
 		return EDIT_TODO_INCORRECT;
 	}
 
+	if (action == ACTION_REWIND) {
+		unlink(todo_file_orig);
+		unlink(done_file_orig);
+	}
+
 	/*
 	 * See if branches need to be added or removed from the update-refs
 	 * file based on the new todo list.
diff --git a/rebase-interactive.h b/rebase-interactive.h
index 5aa4111b4f..260dc7c53f 100644
--- a/rebase-interactive.h
+++ b/rebase-interactive.h
@@ -12,6 +12,8 @@  enum rebase_action {
 	ACTION_ABORT,
 	ACTION_QUIT,
 	ACTION_EDIT_TODO,
+	ACTION_REWIND,
+	ACTION_RESUME_REWIND,
 	ACTION_SHOW_CURRENT_PATCH,
 	ACTION_LAST
 };
diff --git a/sequencer.c b/sequencer.c
index 0b4d16b8e8..0e1d92b238 100644
--- a/sequencer.c
+++ b/sequencer.c
@@ -62,15 +62,17 @@  static GIT_PATH_FUNC(rebase_path, "rebase-merge")
  */
 GIT_PATH_FUNC(rebase_path_todo, "rebase-merge/git-rebase-todo")
 GIT_PATH_FUNC(rebase_path_todo_backup, "rebase-merge/git-rebase-todo.backup")
+GIT_PATH_FUNC(rebase_path_todo_orig, "rebase-merge/git-rebase-todo.orig")
 
 GIT_PATH_FUNC(rebase_path_dropped, "rebase-merge/dropped")
 
 /*
  * The rebase command lines that have already been processed. A line
  * is moved here when it is first handled, before any associated user
  * actions.
  */
-static GIT_PATH_FUNC(rebase_path_done, "rebase-merge/done")
+GIT_PATH_FUNC(rebase_path_done, "rebase-merge/done")
+GIT_PATH_FUNC(rebase_path_done_orig, "rebase-merge/done.orig")
 /*
  * The file to keep track of how many commands were already processed (e.g.
  * for the prompt).
@@ -6113,7 +6115,10 @@  int complete_action(struct repository *r, struct replay_opts *opts, unsigned fla
 	struct strbuf *buf = &todo_list->buf;
 	int res;
 
-	find_unique_abbrev_r(shortonto, onto, DEFAULT_ABBREV);
+	if (action == ACTION_NONE)
+		find_unique_abbrev_r(shortonto, onto, DEFAULT_ABBREV);
+	else if (read_populate_opts(opts))
+		return -1;
 
 	if (buf->len == 0) {
 		struct todo_item *item = append_new_todo(todo_list);
@@ -6143,11 +6148,20 @@  int complete_action(struct repository *r, struct replay_opts *opts, unsigned fla
 	if (res == EDIT_TODO_IOERROR)
 		return -1;
 	else if (res == EDIT_TODO_FAILED) {
+		if (action == ACTION_REWIND)
+			return -1;
+
 		apply_autostash(rebase_path_autostash());
 		sequencer_remove_state(opts);
 
 		return -1;
 	} else if (res == EDIT_TODO_ABORT) {
+		if (action == ACTION_REWIND) {
+			todo_list_release(&new_todo);
+
+			return error(_("rewind aborted; state unchanged"));
+		}
+
 		apply_autostash(rebase_path_autostash());
 		sequencer_remove_state(opts);
 		todo_list_release(&new_todo);
@@ -6239,7 +6253,7 @@  static int skip_fixupish(const char *subject, const char **p) {
 int todo_list_rearrange_squash(struct todo_list *todo_list)
 {
 	struct hashmap subject2item;
-	int rearranged = 0, *next, *tail, i, nr = 0;
+	int rearranged = 0, *next, *tail, i, j, nr = 0;
 	char **subjects;
 	struct commit_todo_item commit_todo;
 	struct todo_item *items = NULL;
@@ -6266,6 +6280,10 @@  int todo_list_rearrange_squash(struct todo_list *todo_list)
 		int i2 = -1;
 		struct subject2item_entry *entry;
 
+		// When rewinding, process only up to the marker.
+		if (item->command == TODO_BREAK)
+			break;
+
 		next[i] = tail[i] = -1;
 		if (!item->commit || item->command == TODO_DROP) {
 			subjects[i] = NULL;
@@ -6350,9 +6368,9 @@  int todo_list_rearrange_squash(struct todo_list *todo_list)
 	if (rearranged) {
 		items = ALLOC_ARRAY(items, todo_list->nr);
 
-		for (i = 0; i < todo_list->nr; i++) {
-			enum todo_command command = todo_list->items[i].command;
-			int cur = i;
+		for (j = 0; j < i; j++) {
+			enum todo_command command = todo_list->items[j].command;
+			int cur = j;
 
 			/*
 			 * Initially, all commands are 'pick's. If it is a
@@ -6367,16 +6385,19 @@  int todo_list_rearrange_squash(struct todo_list *todo_list)
 			}
 		}
 
+		for (; j < todo_list->nr; j++)
+			items[nr++] = todo_list->items[j];
+
 		assert(nr == todo_list->nr);
 		todo_list->alloc = nr;
 		FREE_AND_NULL(todo_list->items);
 		todo_list->items = items;
 	}
 
 	free(next);
 	free(tail);
-	for (i = 0; i < todo_list->nr; i++)
-		free(subjects[i]);
+	for (j = 0; j < i; j++)
+		free(subjects[j]);
 	free(subjects);
 	hashmap_clear_and_free(&subject2item, struct subject2item_entry, entry);
 
diff --git a/sequencer.h b/sequencer.h
index 33bcff89e0..84d7c076c0 100644
--- a/sequencer.h
+++ b/sequencer.h
@@ -12,7 +12,10 @@  struct repository;
 const char *git_path_commit_editmsg(void);
 const char *rebase_path_todo(void);
 const char *rebase_path_todo_backup(void);
+const char *rebase_path_todo_orig(void);
 const char *rebase_path_dropped(void);
+const char *rebase_path_done(void);
+const char *rebase_path_done_orig(void);
 
 #define APPEND_SIGNOFF_DEDUP (1u << 0)
 
diff --git a/t/t3404-rebase-interactive.sh b/t/t3404-rebase-interactive.sh
index dd47f0bbce..ef68f4470a 100755
--- a/t/t3404-rebase-interactive.sh
+++ b/t/t3404-rebase-interactive.sh
@@ -2180,6 +2180,117 @@  test_expect_success 'bad labels and refs rejected when parsing todo list' '
 	test_path_is_missing execed
 '
 
+test_expect_success 'rebase --rewind' '
+	git checkout primary^0 &&
+	(
+		set_fake_editor &&
+		FAKE_LINES="1 reword 2 3 break 4" \
+			FAKE_COMMIT_MESSAGE="C_reworded" \
+			git rebase -i HEAD~4 &&
+		FAKE_LINES="reword 1 2 3 6" \
+			FAKE_COMMIT_MESSAGE="B_reworded" \
+			git rebase --rewind >output 2>&1
+	) &&
+	grep -q "Rebasing (4/4)" output &&
+	test "$(git log -1 --format=%B HEAD~3)" = "B_reworded" &&
+	test "$(git log -1 --format=%B HEAD~2)" = "C_reworded"
+'
+
+test_expect_success 'rebase --rewind with initially botched todo' '
+	git checkout primary^0 &&
+	(
+		set_fake_editor &&
+		FAKE_LINES="1 reword 2 3 break 4" \
+			FAKE_COMMIT_MESSAGE="C_reworded" \
+			git rebase -i HEAD~4 &&
+		test_must_fail env FAKE_LINES="reword 1 bad 2 3 6" \
+			git rebase --rewind &&
+		grep -q "rewinding" < .git/rebase-merge/git-rebase-todo.backup &&
+		FAKE_LINES="reword 1 pick 2 3 4" \
+			git rebase --edit-todo &&
+		FAKE_COMMIT_MESSAGE="B_reworded" \
+			git rebase --continue
+	) &&
+	test "$(git log -1 --format=%B HEAD~3)" = "B_reworded" &&
+	test "$(git log -1 --format=%B HEAD~2)" = "C_reworded"
+'
+
+test_expect_success 'recursing rebase --rewind with initially botched todo' '
+	git checkout primary^0 &&
+	cat >expect <<-\EOF &&
+	error: you are already rewinding a rebase.
+	Use rebase --edit-todo to continue.
+	EOF
+	(
+		set_fake_editor &&
+		FAKE_LINES="1 reword 2 3 break 4" \
+			FAKE_COMMIT_MESSAGE="C_reworded" \
+			git rebase -i HEAD~4 &&
+		test_must_fail env FAKE_LINES="reword 1 bad 2 3 6" \
+			git rebase --rewind &&
+		test_must_fail env FAKE_LINES="reword 1 pick 2 3 4" \
+			git rebase --rewind >actual 2>&1  &&
+		test_cmp expect actual &&
+		git rebase --abort
+	)
+'
+
+test_expect_success 'rebase --rewind being aborted' '
+	git checkout primary^0 &&
+	cat >expect <<-\EOF &&
+	error: rewind aborted; state unchanged
+	EOF
+	(
+		set_fake_editor &&
+		FAKE_LINES="1 reword 2 3 break 4" \
+			FAKE_COMMIT_MESSAGE="C_reworded" \
+			git rebase -i HEAD~4 &&
+		test_must_fail env FAKE_LINES="#" \
+			git rebase --rewind >output 2>&1 &&
+		tail -n 1 output >actual &&  # Ignore output about changing todo list
+		test_cmp expect actual &&
+		git rebase --continue
+	) &&
+	test "$(git log -1 --format=%B HEAD~2)" = "C_reworded"
+'
+
+test_expect_success 'rebase --rewind being aborted after initially botched todo' '
+	git checkout primary^0 &&
+	cat >expect <<-\EOF &&
+	error: rewind aborted; state restored
+	EOF
+	(
+		set_fake_editor &&
+		FAKE_LINES="1 reword 2 3 break 4" \
+			FAKE_COMMIT_MESSAGE="C_reworded" \
+			git rebase -i HEAD~4 &&
+		test_must_fail env FAKE_LINES="reword 1 bad 2 3 6" \
+			git rebase --rewind &&
+		test_must_fail env FAKE_LINES="#" \
+			git rebase --edit-todo >output 2>&1 &&
+		tail -n 1 output >actual &&  # Ignore output about changing todo list
+		test_cmp expect actual &&
+		git rebase --continue
+	) &&
+	test "$(git log -1 --format=%B HEAD~2)" = "C_reworded"
+'
+
+test_expect_failure 'rebase --rewind vs. --update-refs' '
+	git checkout primary^0 &&
+	git branch -f first HEAD~3 &&
+	(
+		set_fake_editor &&
+		FAKE_LINES="1 2 reword 4 5 break 6 7" \
+			FAKE_COMMIT_MESSAGE="C_reworded" \
+			git rebase -i --update-refs HEAD~4 &&
+		FAKE_LINES="reword 1 2 3 4 7 8" \
+			FAKE_COMMIT_MESSAGE="B_reworded" \
+			git rebase --rewind
+	) &&
+	test_cmp_rev HEAD~3 refs/heads/first &&
+	test_cmp_rev HEAD refs/heads/primary
+'
+
 # This must be the last test in this file
 test_expect_success '$EDITOR and friends are unchanged' '
 	test_editor_unchanged