diff mbox series

rebase: apply and cleanup autostash when rebase fails to start

Message ID pull.1772.git.1723641747309.gitgitgadget@gmail.com (mailing list archive)
State Superseded
Headers show
Series rebase: apply and cleanup autostash when rebase fails to start | expand

Commit Message

Phillip Wood Aug. 14, 2024, 1:22 p.m. UTC
From: Phillip Wood <phillip.wood@dunelm.org.uk>

If "git rebase" fails to start after stashing the user's uncommitted
changes then it forgets to restore the stashed changes and remove state
directory. To make matters worse running "git rebase --abort" to apply
the stashed changes and cleanup the state directory fails because the
state directory only contains the "autostash" file and is missing the
"head-name" and "onto" required by read_basic_state().

Fix this by applying the autostash and removing the state directory if
the pre-rebase hook or initial checkout fail. New tests are added to
check the autostash is applied and the state directory is removed if the
rebase fails to start. Checks are also added to some existing tests in
order to ensure there is no state directory left behind when a rebase
fails to start and no autostash has been created.

Reported-by: Brian Lyles <brianmlyles@gmail.com>
Signed-off-by: Phillip Wood <phillip.wood@dunelm.org.uk>
---
    rebase: apply and cleanup autostash when rebase fails to start
    
    Thanks to Brian for reporting this. This patch is based on maint.

Published-As: https://github.com/gitgitgadget/git/releases/tag/pr-1772%2Fphillipwood%2Frebase-fix-autostash-v1
Fetch-It-Via: git fetch https://github.com/gitgitgadget/git pr-1772/phillipwood/rebase-fix-autostash-v1
Pull-Request: https://github.com/gitgitgadget/git/pull/1772

 builtin/rebase.c            | 39 +++++++++++++++++++++++++++++-------
 t/t3400-rebase.sh           |  8 ++++++--
 t/t3413-rebase-hook.sh      |  4 +++-
 t/t3420-rebase-autostash.sh | 40 +++++++++++++++++++++++++++++++++++++
 4 files changed, 81 insertions(+), 10 deletions(-)


base-commit: 39bf06adf96da25b87c9aa7d35a32ef3683eb4a4

Comments

Patrick Steinhardt Aug. 14, 2024, 2:36 p.m. UTC | #1
On Wed, Aug 14, 2024 at 01:22:27PM +0000, Phillip Wood via GitGitGadget wrote:
> From: Phillip Wood <phillip.wood@dunelm.org.uk>
> 
> If "git rebase" fails to start after stashing the user's uncommitted
> changes then it forgets to restore the stashed changes and remove state

s/remove/& the/

> directory. To make matters worse running "git rebase --abort" to apply

s/worse/&,/

> the stashed changes and cleanup the state directory fails because the

s/cleanup/& of/

> diff --git a/builtin/rebase.c b/builtin/rebase.c
> index e3a8e74cfc2..ac23c4ddbb0 100644
> --- a/builtin/rebase.c
> +++ b/builtin/rebase.c
> @@ -526,6 +526,23 @@ static int rebase_write_basic_state(struct rebase_options *opts)
>  	return 0;
>  }
>  
> +static int cleanup_autostash(struct rebase_options *opts)
> +{
> +	int ret;
> +	struct strbuf dir = STRBUF_INIT;
> +	const char *path = state_dir_path("autostash", opts);
> +
> +	if (!file_exists(path))
> +		return 0;
> +	ret = apply_autostash(path);
> +	strbuf_addstr(&dir, opts->state_dir);
> +	if (remove_dir_recursively(&dir, 0))
> +		ret = error_errno(_("could not remove '%s'"), opts->state_dir);

This seems somewhat dangerous to me though. Should we really delete the
autostash directory in case applying the autostash has failed?

> @@ -1851,9 +1871,14 @@ run_rebase:
>  
>  cleanup:
>  	strbuf_release(&buf);
> +	strbuf_release(&msg);
>  	strbuf_release(&revisions);
>  	rebase_options_release(&options);
>  	free(squash_onto_name);
>  	free(keep_base_onto_name);
>  	return !!ret;
> +
> +cleanup_autostash:
> +	ret |= !!cleanup_autostash(&options);
> +	goto cleanup;
>  }

It's somewhat curious of a code flow to jump backwards. It might be
easier to reason about the flow if we kept track of a variable
`autostash_needs_cleanup` that we set such that all jumps can go to the
`cleanup` label instead.

Patrick
Phillip Wood Aug. 14, 2024, 3:59 p.m. UTC | #2
Hi Patrick

On 14/08/2024 15:36, Patrick Steinhardt wrote:
> On Wed, Aug 14, 2024 at 01:22:27PM +0000, Phillip Wood via GitGitGadget wrote:
>> From: Phillip Wood <phillip.wood@dunelm.org.uk>
>>
>> If "git rebase" fails to start after stashing the user's uncommitted
>> changes then it forgets to restore the stashed changes and remove state
> 
> s/remove/& the/
> 
>> directory. To make matters worse running "git rebase --abort" to apply
> 
> s/worse/&,/
> 
>> the stashed changes and cleanup the state directory fails because the
> 
> s/cleanup/& of/

Thanks for catching those typos

>> diff --git a/builtin/rebase.c b/builtin/rebase.c
>> index e3a8e74cfc2..ac23c4ddbb0 100644
>> --- a/builtin/rebase.c
>> +++ b/builtin/rebase.c
>> @@ -526,6 +526,23 @@ static int rebase_write_basic_state(struct rebase_options *opts)
>>   	return 0;
>>   }
>>   
>> +static int cleanup_autostash(struct rebase_options *opts)
>> +{
>> +	int ret;
>> +	struct strbuf dir = STRBUF_INIT;
>> +	const char *path = state_dir_path("autostash", opts);
>> +
>> +	if (!file_exists(path))
>> +		return 0;
>> +	ret = apply_autostash(path);
>> +	strbuf_addstr(&dir, opts->state_dir);
>> +	if (remove_dir_recursively(&dir, 0))
>> +		ret = error_errno(_("could not remove '%s'"), opts->state_dir);
> 
> This seems somewhat dangerous to me though. Should we really delete the
> autostash directory in case applying the autostash has failed?

Applying the stash should not fail because the rebase has not started 
and so HEAD, the index and the worktree are unchanged since the stash 
was created. If it does fail for some reason then apply_autostash() 
creates a new entry under refs/stash. We definitely do want to remove 
the directory otherwise we're left with the inconsistent state we're 
tying to fix.

>> @@ -1851,9 +1871,14 @@ run_rebase:
>>   
>>   cleanup:
>>   	strbuf_release(&buf);
>> +	strbuf_release(&msg);
>>   	strbuf_release(&revisions);
>>   	rebase_options_release(&options);
>>   	free(squash_onto_name);
>>   	free(keep_base_onto_name);
>>   	return !!ret;
>> +
>> +cleanup_autostash:
>> +	ret |= !!cleanup_autostash(&options);
>> +	goto cleanup;
>>   }
> 
> It's somewhat curious of a code flow to jump backwards. It might be
> easier to reason about the flow if we kept track of a variable
> `autostash_needs_cleanup` that we set such that all jumps can go to the
> `cleanup` label instead.

It is slightly odd, but in the end I decided it was simpler to say "goto 
cleanup_autostash" than try and keep track of what needs cleaning up 
when saying "goto cleanup". There are a couple of similar examples in 
builtin/stash.c:show_stash() and 
config.c:git_config_set_multivar_in_file_gently()

Thanks for the review

Phillip


> Patrick
>
Junio C Hamano Aug. 14, 2024, 5:27 p.m. UTC | #3
Phillip Wood <phillip.wood123@gmail.com> writes:

> Applying the stash should not fail because the rebase has not started
> and so HEAD, the index and the worktree are unchanged since the stash
> was created. If it does fail for some reason then apply_autostash()
> creates a new entry under refs/stash. We definitely do want to remove
> the directory otherwise we're left with the inconsistent state we're
> tying to fix.

If it is not expected to fail 99% of times, it feels more prudent to
abort loudly without making further damage to lose information and
ask the user to check what happened in the working tree, rather than
blindly removing the clue to understand what went wrong.  For
example, could the reason why applying the stash failed be because
the user forgot that the working tree was being used for rebasing
and mucked with its contents from say another terminal?

Thanks.
Phillip Wood Aug. 15, 2024, 9:47 a.m. UTC | #4
Hi Junio

On 14/08/2024 18:27, Junio C Hamano wrote:
> Phillip Wood <phillip.wood123@gmail.com> writes:
> 
>> Applying the stash should not fail because the rebase has not started
>> and so HEAD, the index and the worktree are unchanged since the stash
>> was created. If it does fail for some reason then apply_autostash()
>> creates a new entry under refs/stash. We definitely do want to remove
>> the directory otherwise we're left with the inconsistent state we're
>> tying to fix.
> 
> If it is not expected to fail 99% of times, it feels more prudent to
> abort loudly without making further damage to lose information and
> ask the user to check what happened in the working tree, rather than
> blindly removing the clue to understand what went wrong.  For
> example, could the reason why applying the stash failed be because
> the user forgot that the working tree was being used for rebasing
> and mucked with its contents from say another terminal?

If the working tree has changed then the stash will still apply but 
possibly with conflicts - the same thing can happen when the branch has 
been successfully rebased. If there are conflicts then the stash is 
saved in refs/stash as well. This code is just doing what we do at the 
end of a successful rebase so I'm don't really understand what the issue 
is. Looking at finish_rebase() we don't even check the return value of 
apply_autostash() when applying the stash at the end of a successful rebase.

Best Wishes

Phillip

> Thanks.
>
Junio C Hamano Sept. 8, 2024, 9:54 p.m. UTC | #5
Phillip Wood <phillip.wood123@gmail.com> writes:

> ... This code is just doing what we do at
> the end of a successful rebase so I'm don't really understand what the
> issue is. Looking at finish_rebase() we don't even check the return
> value of apply_autostash() when applying the stash at the end of a
> successful rebase.

At that point we give control back the user, so if things are left
in conflicted or any other "unexpected" funny state, the user kill
keep the both halves.  As long as the user clearly understands why
the working tree is in such a funny state, we should be OK (and I
would imagine that we are giving messages like "applying preexisting
changes stashed away before rebasing" or something).

Thanks.
Junio C Hamano Sept. 8, 2024, 9:54 p.m. UTC | #6
Phillip Wood <phillip.wood123@gmail.com> writes:

> ... This code is just doing what we do at
> the end of a successful rebase so I'm don't really understand what the
> issue is. Looking at finish_rebase() we don't even check the return
> value of apply_autostash() when applying the stash at the end of a
> successful rebase.

At that point we give control back the user, so if things are left
in conflicted or any other "unexpected" funny state, the user kill
keep the both halves.  As long as the user clearly understands why
the working tree is in such a funny state, we should be OK (and I
would imagine that we are giving messages like "applying preexisting
changes stashed away before rebasing" or something).

Thanks.
diff mbox series

Patch

diff --git a/builtin/rebase.c b/builtin/rebase.c
index e3a8e74cfc2..ac23c4ddbb0 100644
--- a/builtin/rebase.c
+++ b/builtin/rebase.c
@@ -526,6 +526,23 @@  static int rebase_write_basic_state(struct rebase_options *opts)
 	return 0;
 }
 
+static int cleanup_autostash(struct rebase_options *opts)
+{
+	int ret;
+	struct strbuf dir = STRBUF_INIT;
+	const char *path = state_dir_path("autostash", opts);
+
+	if (!file_exists(path))
+		return 0;
+	ret = apply_autostash(path);
+	strbuf_addstr(&dir, opts->state_dir);
+	if (remove_dir_recursively(&dir, 0))
+		ret = error_errno(_("could not remove '%s'"), opts->state_dir);
+	strbuf_release(&dir);
+
+	return ret;
+}
+
 static int finish_rebase(struct rebase_options *opts)
 {
 	struct strbuf dir = STRBUF_INIT;
@@ -1726,7 +1743,7 @@  int cmd_rebase(int argc, const char **argv, const char *prefix)
 	if (require_clean_work_tree(the_repository, "rebase",
 				    _("Please commit or stash them."), 1, 1)) {
 		ret = -1;
-		goto cleanup;
+		goto cleanup_autostash;
 	}
 
 	/*
@@ -1749,7 +1766,7 @@  int cmd_rebase(int argc, const char **argv, const char *prefix)
 			if (options.switch_to) {
 				ret = checkout_up_to_date(&options);
 				if (ret)
-					goto cleanup;
+					goto cleanup_autostash;
 			}
 
 			if (!(options.flags & REBASE_NO_QUIET))
@@ -1775,8 +1792,10 @@  int cmd_rebase(int argc, const char **argv, const char *prefix)
 	/* If a hook exists, give it a chance to interrupt*/
 	if (!ok_to_skip_pre_rebase &&
 	    run_hooks_l("pre-rebase", options.upstream_arg,
-			argc ? argv[0] : NULL, NULL))
-		die(_("The pre-rebase hook refused to rebase."));
+			argc ? argv[0] : NULL, NULL)) {
+		ret = error(_("The pre-rebase hook refused to rebase."));
+		goto cleanup_autostash;
+	}
 
 	if (options.flags & REBASE_DIFFSTAT) {
 		struct diff_options opts;
@@ -1821,9 +1840,10 @@  int cmd_rebase(int argc, const char **argv, const char *prefix)
 			RESET_HEAD_RUN_POST_CHECKOUT_HOOK;
 	ropts.head_msg = msg.buf;
 	ropts.default_reflog_action = options.reflog_action;
-	if (reset_head(the_repository, &ropts))
-		die(_("Could not detach HEAD"));
-	strbuf_release(&msg);
+	if (reset_head(the_repository, &ropts)) {
+		ret = error(_("Could not detach HEAD"));
+		goto cleanup_autostash;
+	}
 
 	/*
 	 * If the onto is a proper descendant of the tip of the branch, then
@@ -1851,9 +1871,14 @@  run_rebase:
 
 cleanup:
 	strbuf_release(&buf);
+	strbuf_release(&msg);
 	strbuf_release(&revisions);
 	rebase_options_release(&options);
 	free(squash_onto_name);
 	free(keep_base_onto_name);
 	return !!ret;
+
+cleanup_autostash:
+	ret |= !!cleanup_autostash(&options);
+	goto cleanup;
 }
diff --git a/t/t3400-rebase.sh b/t/t3400-rebase.sh
index ae34bfad607..972ea85febc 100755
--- a/t/t3400-rebase.sh
+++ b/t/t3400-rebase.sh
@@ -145,7 +145,9 @@  test_expect_success 'Show verbose error when HEAD could not be detached' '
 	test_when_finished "rm -f B" &&
 	test_must_fail git rebase topic 2>output.err >output.out &&
 	test_grep "The following untracked working tree files would be overwritten by checkout:" output.err &&
-	test_grep B output.err
+	test_grep B output.err &&
+	test_must_fail git rebase --quit 2>err &&
+	test_grep "no rebase in progress" err
 '
 
 test_expect_success 'fail when upstream arg is missing and not on branch' '
@@ -422,7 +424,9 @@  test_expect_success 'refuse to switch to branch checked out elsewhere' '
 	git checkout main &&
 	git worktree add wt &&
 	test_must_fail git -C wt rebase main main 2>err &&
-	test_grep "already used by worktree at" err
+	test_grep "already used by worktree at" err &&
+	test_must_fail git -C wt rebase --quit 2>err &&
+	test_grep "no rebase in progress" err
 '
 
 test_expect_success 'rebase when inside worktree subdirectory' '
diff --git a/t/t3413-rebase-hook.sh b/t/t3413-rebase-hook.sh
index e8456831e8b..426ff098e1d 100755
--- a/t/t3413-rebase-hook.sh
+++ b/t/t3413-rebase-hook.sh
@@ -110,7 +110,9 @@  test_expect_success 'pre-rebase hook stops rebase (1)' '
 	git reset --hard side &&
 	test_must_fail git rebase main &&
 	test "z$(git symbolic-ref HEAD)" = zrefs/heads/test &&
-	test 0 = $(git rev-list HEAD...side | wc -l)
+	test 0 = $(git rev-list HEAD...side | wc -l) &&
+	test_must_fail git rebase --quit 2>err &&
+	test_grep "no rebase in progress" err
 '
 
 test_expect_success 'pre-rebase hook stops rebase (2)' '
diff --git a/t/t3420-rebase-autostash.sh b/t/t3420-rebase-autostash.sh
index 63e400b89f2..b43046b3b0d 100755
--- a/t/t3420-rebase-autostash.sh
+++ b/t/t3420-rebase-autostash.sh
@@ -82,6 +82,46 @@  testrebase () {
 	type=$1
 	dotest=$2
 
+	test_expect_success "rebase$type: restore autostash when pre-rebase hook fails" '
+		git checkout -f feature-branch &&
+		test_hook pre-rebase <<-\EOF &&
+		exit 1
+		EOF
+
+		echo changed >file0 &&
+		test_must_fail git rebase $type --autostash -f HEAD^ &&
+		test_must_fail git rebase --quit 2>err &&
+		test_grep "no rebase in progress" err &&
+		echo changed >expect &&
+		test_cmp expect file0
+	'
+
+	test_expect_success "rebase$type: restore autostash when checkout onto fails" '
+		git checkout -f --detach feature-branch &&
+		echo uncommitted-content >file0 &&
+		echo untracked >file4 &&
+		test_when_finished "rm file4" &&
+		test_must_fail git rebase $type --autostash \
+							unrelated-onto-branch &&
+		test_must_fail git rebase --quit 2>err &&
+		test_grep "no rebase in progress" err &&
+		echo uncommitted-content >expect &&
+		test_cmp expect file0
+	'
+
+	test_expect_success "rebase$type: restore autostash when branch checkout fails" '
+		git checkout -f unrelated-onto-branch^ &&
+		echo uncommitted-content >file0 &&
+		echo untracked >file4 &&
+		test_when_finished "rm file4" &&
+		test_must_fail git rebase $type --autostash HEAD \
+							unrelated-onto-branch &&
+		test_must_fail git rebase --quit 2>err &&
+		test_grep "no rebase in progress" err &&
+		echo uncommitted-content >expect &&
+		test_cmp expect file0
+	'
+
 	test_expect_success "rebase$type: dirty worktree, --no-autostash" '
 		test_config rebase.autostash true &&
 		git reset --hard &&