diff mbox series

[v2] rebase: teach rebase --keep-base

Message ID 20190328221745.GA3941@dev-l (mailing list archive)
State New, archived
Headers show
Series [v2] rebase: teach rebase --keep-base | expand

Commit Message

Denton Liu March 28, 2019, 10:17 p.m. UTC
A common scenario is if a user is working on a topic branch and they
wish to make some changes to intermediate commits or autosquash, they
would run something such as

	git rebase -i --onto master... master

in order to preserve the merge base. This prevents unnecessary commit
churning.

Alternatively, a user wishing to test individual commits in a topic
branch without changing anything may run

	git rebase -x ./test.sh master... master

Since rebasing onto the merge base of the branch and the upstream is
such a common case, introduce the --keep-base option as a shortcut.

This allows us to rewrite the above as

	git rebase -i --keep-base master

and

	git rebase -x ./test.sh --keep-base master

respectively.

Add tests to ensure --keep-base works correctly in the normal case and
fails when there are multiple merge bases, both in regular and
interactive mode. Also, test to make sure conflicting options cause
rebase to fail. While we're adding test cases, add a missing
set_fake_editor call to 'rebase -i --onto master...side'.

While we're documenting the --keep-base option, change an instance of
"merge-base" to "merge base", which is the consistent spelling.

Helped-by: Eric Sunshine <sunshine@sunshineco.com>
Helped-by: Junio C Hamano <gitster@pobox.com>
Helped-by: Ævar Arnfjörð Bjarmason <avarab@gmail.com>
Signed-off-by: Denton Liu <liu.denton@gmail.com>
---

Ævar, I have a feeling that we're still miscommunicating and we don't
fully understand each other. I'm putting up v2 to hopefully clear things
up a little but I welcome more feedback.

This patch now depends "[PATCH 1/8] tests (rebase): spell out the
`--keep-empty` option" which is the first patch of Johannes's "Do not
use abbreviated options in tests" patchset[1]. (Thanks for catching
that, Johannes!)

Changes since v1:

* Squashed old set into one patch
* Fixed indentation style and dangling else
* Added more documentation after discussion with Ævar

[1]: https://public-inbox.org/git/a1b4b74b9167e279dae4cd8c58fb28d8a714a66a.1553537656.git.gitgitgadget@gmail.com/

 Documentation/git-rebase.txt     | 25 ++++++++++++--
 builtin/rebase.c                 | 32 ++++++++++++++----
 t/t3416-rebase-onto-threedots.sh | 57 ++++++++++++++++++++++++++++++++
 3 files changed, 105 insertions(+), 9 deletions(-)

Comments

Ævar Arnfjörð Bjarmason March 28, 2019, 11:13 p.m. UTC | #1
On Thu, Mar 28 2019, Denton Liu wrote:

> A common scenario is if a user is working on a topic branch and they
> wish to make some changes to intermediate commits or autosquash, they
> would run something such as
>
> 	git rebase -i --onto master... master
>
> in order to preserve the merge base. This prevents unnecessary commit
> churning.
>
> Alternatively, a user wishing to test individual commits in a topic
> branch without changing anything may run
>
> 	git rebase -x ./test.sh master... master
>
> Since rebasing onto the merge base of the branch and the upstream is
> such a common case, introduce the --keep-base option as a shortcut.
>
> This allows us to rewrite the above as
>
> 	git rebase -i --keep-base master
>
> and
>
> 	git rebase -x ./test.sh --keep-base master
>
> respectively.
>
> Add tests to ensure --keep-base works correctly in the normal case and
> fails when there are multiple merge bases, both in regular and
> interactive mode. Also, test to make sure conflicting options cause
> rebase to fail. While we're adding test cases, add a missing
> set_fake_editor call to 'rebase -i --onto master...side'.
>
> While we're documenting the --keep-base option, change an instance of
> "merge-base" to "merge base", which is the consistent spelling.
>
> Helped-by: Eric Sunshine <sunshine@sunshineco.com>
> Helped-by: Junio C Hamano <gitster@pobox.com>
> Helped-by: Ævar Arnfjörð Bjarmason <avarab@gmail.com>
> Signed-off-by: Denton Liu <liu.denton@gmail.com>
> ---
>
> Ævar, I have a feeling that we're still miscommunicating and we don't
> fully understand each other. I'm putting up v2 to hopefully clear things
> up a little but I welcome more feedback.
>
> This patch now depends "[PATCH 1/8] tests (rebase): spell out the
> `--keep-empty` option" which is the first patch of Johannes's "Do not
> use abbreviated options in tests" patchset[1]. (Thanks for catching
> that, Johannes!)

Yeah I'm still confused. Gotta go now, but just some early poking I did.

First, there's now docs saying "starting point" v.s. "fork point", but
the tests are still the same, i.e. the ones I can just replace with
either of `git merge-base [--fork-point] @{u} master`.

It would really help if the tests actually stressed the parts where this
is different, including argument-less versions. I.e. just "git rebase
--keep-base".

Speaking of that, even though you say this is different than
"--fork-point" you may or may not have noticed that if "argc < 1" you
*still* go through the whole fork_point codepath, which will set
"options.restrict_revision" for you. This is part of what I mentioned
upthread. I.e. with this on top of this patch all your tests still pass:

    diff --git a/builtin/rebase.c b/builtin/rebase.c
    index 3347dd8975..e38a5044eb 100644
    --- a/builtin/rebase.c
    +++ b/builtin/rebase.c
    @@ -1515,7 +1515,7 @@ int cmd_rebase(int argc, const char **argv, const char *prefix)
     								    NULL);
     			if (!options.upstream_name)
     				error_on_missing_default_upstream();
    -			if (fork_point < 0)
    +			if (fork_point < 0 && !keep_base)
     				fork_point = 1;
     		} else {
     			options.upstream_name = argv[0];
    @@ -1524,9 +1524,11 @@ int cmd_rebase(int argc, const char **argv, const char *prefix)
     			if (!strcmp(options.upstream_name, "-"))
     				options.upstream_name = "@{-1}";
     		}
    -		options.upstream = peel_committish(options.upstream_name);
    -		if (!options.upstream)
    -			die(_("invalid upstream '%s'"), options.upstream_name);
    +		if (!keep_base) {
    +			options.upstream = peel_committish(options.upstream_name);
    +			if (!options.upstream)
    +				/* I suppose we need to keep this die(...) somewhere still... */
    +				die(_("invalid upstream '%s'"), options.upstream_name);
    +		}
     		options.upstream_arg = options.upstream_name;
     	} else {
     		if (!options.onto_name) {
    @@ -1564,6 +1566,8 @@ int cmd_rebase(int argc, const char **argv, const char *prefix)
     		}
     		options.onto = lookup_commit_or_die(&merge_base,
     						    options.onto_name);
    +		if (keep_base)
    +			options.upstream = options.onto;
     	} else {
     		options.onto = peel_committish(options.onto_name);
     		if (!options.onto)

But now because (and bear with me, I don't really get all this) we are
not implicitly setting options.restrict_revision later in the
"fork_point > 0" case *and* our "options.upstream" SHA-1 is the base you
find with --keep-base instead of the @{u} SHA-1 the
can_fast_forward(...) "if" kicks in, so now:

    $ git status
    On branch master
    Your branch and 'origin/master' have diverged,
    and have 3 and 37 different commits each, respectively.

    # Here your version would always re-rebase it
    $ ~/g/git/git --exec-path=$PWD rebase --keep-base
    Current branch master is up to date.

But "-i" still works as intended:

    $ ~/g/git/git --exec-path=$PWD rebase --keep-base -i
    hint: Waiting for your editor to close the file... Waiting for Emacs...
    Successfully rebased and updated refs/heads/master.
    $ git status
    On branch master
    Your branch and 'origin/master' have diverged,
    and have 3 and 37 different commits each, respectively.

I don't know about the *other* use-cases you have in mind, but I can't
see a reason for why *that* simple case shouldn't work like that. Why
would we redundantly rebase every time in this case, just becase some
mode of --onto does it? I think if anything we should teach it the same
lazyness, or maybe that breaks stuff (what stuff?).

Peff discussed some of these variables & their interaction in
https://public-inbox.org/git/20190224101029.GA13438@sigill.intra.peff.net/

I just re-read that, but this whole thing has paged out of my brain in
the meantime, and on my quick reading before sending this E-Mail I'm
still not sure what all the subtleties involved are.

But one thing's for sure, I think exhaustive testing of all the edge
cases involved will make this a lot clearer. I.e.:

 * Here upstream has diverged, we rebase (and in the "noop case?)
 * Here upstream has diverged, *and* rewound (and in the "noop case?)
 * etc. etc.

> Changes since v1:
>
> * Squashed old set into one patch
> * Fixed indentation style and dangling else
> * Added more documentation after discussion with Ævar
>
> [1]: https://public-inbox.org/git/a1b4b74b9167e279dae4cd8c58fb28d8a714a66a.1553537656.git.gitgitgadget@gmail.com/
>
>  Documentation/git-rebase.txt     | 25 ++++++++++++--
>  builtin/rebase.c                 | 32 ++++++++++++++----
>  t/t3416-rebase-onto-threedots.sh | 57 ++++++++++++++++++++++++++++++++
>  3 files changed, 105 insertions(+), 9 deletions(-)
>
> diff --git a/Documentation/git-rebase.txt b/Documentation/git-rebase.txt
> index 6363d674b7..27be1f48ff 100644
> --- a/Documentation/git-rebase.txt
> +++ b/Documentation/git-rebase.txt
> @@ -8,8 +8,8 @@ git-rebase - Reapply commits on top of another base tip
>  SYNOPSIS
>  --------
>  [verse]
> -'git rebase' [-i | --interactive] [<options>] [--exec <cmd>] [--onto <newbase>]
> -	[<upstream> [<branch>]]
> +'git rebase' [-i | --interactive] [<options>] [--exec <cmd>]
> +	[--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
> @@ -217,6 +217,19 @@ As a special case, you may use "A\...B" as a shortcut for the
>  merge base of A and B if there is exactly one merge base. You can
>  leave out at most one of A and B, in which case it defaults to HEAD.
>
> +--keep-base::
> +	Set the starting point at which to create the new commits to the
> +	merge base of <upstream> <branch>. Running
> +	'git rebase --keep-base <upstream> <branch>' is equivalent to
> +	running 'git rebase --onto <upstream>... <upstream>'.
> ++
> +Although both this option and --fork-point find the merge base between
> +<upstream> and <branch>, this option uses the merge base as the _starting
> +point_ on which new commits will be created, whereas --fork-point uses
> +the merge base to determine the _set of commits_ which will be rebased.
> ++
> +See also INCOMPATIBLE OPTIONS below.
> +
>  <upstream>::
>  	Upstream branch to compare against.  May be any valid commit,
>  	not just an existing branch name. Defaults to the configured
> @@ -364,6 +377,10 @@ ends up being empty, the <upstream> will be used as a fallback.
>  +
>  If either <upstream> or --root is given on the command line, then the
>  default is `--no-fork-point`, otherwise the default is `--fork-point`.
> ++
> +If your branch was based on <upstream> but <upstream> was rewound and
> +your branch contains commits which were dropped, this option can be used
> +with `--keep-base` in order to drop those commits from your branch.
>
>  --ignore-whitespace::
>  --whitespace=<option>::
> @@ -539,6 +556,8 @@ In addition, the following pairs of options are incompatible:
>   * --preserve-merges and --rebase-merges
>   * --rebase-merges and --strategy
>   * --rebase-merges and --strategy-option
> + * --keep-base and --onto
> + * --keep-base and --root
>
>  BEHAVIORAL DIFFERENCES
>  -----------------------
> @@ -863,7 +882,7 @@ NOTE: While an "easy case recovery" sometimes appears to be successful
>        --interactive` will be **resurrected**!
>
>  The idea is to manually tell 'git rebase' "where the old 'subsystem'
> -ended and your 'topic' began", that is, what the old merge-base
> +ended and your 'topic' began", that is, what the old merge base
>  between them was.  You will have to find a way to name the last commit
>  of the old 'subsystem', for example:
>
> diff --git a/builtin/rebase.c b/builtin/rebase.c
> index 77deebc65c..7c14a00460 100644
> --- a/builtin/rebase.c
> +++ b/builtin/rebase.c
> @@ -27,8 +27,8 @@
>  #include "branch.h"
>
>  static char const * const builtin_rebase_usage[] = {
> -	N_("git rebase [-i] [options] [--exec <cmd>] [--onto <newbase>] "
> -		"[<upstream>] [<branch>]"),
> +	N_("git rebase [-i] [options] [--exec <cmd>] "
> +		"[--onto <newbase> | --keep-base] [<upstream> [<branch>]]"),
>  	N_("git rebase [-i] [options] [--exec <cmd>] [--onto <newbase>] "
>  		"--root [<branch>]"),
>  	N_("git rebase --continue | --abort | --skip | --edit-todo"),
> @@ -1018,6 +1018,7 @@ int cmd_rebase(int argc, const char **argv, const char *prefix)
>  	};
>  	const char *branch_name;
>  	int ret, flags, total_argc, in_progress = 0;
> +	int keep_base = 0;
>  	int ok_to_skip_pre_rebase = 0;
>  	struct strbuf msg = STRBUF_INIT;
>  	struct strbuf revisions = STRBUF_INIT;
> @@ -1051,6 +1052,8 @@ int cmd_rebase(int argc, const char **argv, const char *prefix)
>  		OPT_STRING(0, "onto", &options.onto_name,
>  			   N_("revision"),
>  			   N_("rebase onto given branch instead of upstream")),
> +		OPT_BOOL(0, "keep-base", &keep_base,
> +			 N_("use the merge-base of upstream and branch as the current base")),
>  		OPT_BOOL(0, "no-verify", &ok_to_skip_pre_rebase,
>  			 N_("allow pre-rebase hook to run")),
>  		OPT_NEGBIT('q', "quiet", &options.flags,
> @@ -1217,6 +1220,13 @@ int cmd_rebase(int argc, const char **argv, const char *prefix)
>  		usage_with_options(builtin_rebase_usage,
>  				   builtin_rebase_options);
>
> +	if (keep_base) {
> +		if (options.onto_name)
> +			die(_("cannot combine '--keep-base' with '--onto'"));
> +		if (options.root)
> +			die(_("cannot combine '--keep-base' with '--root'"));
> +	}
> +
>  	if (action != NO_ACTION && !in_progress)
>  		die(_("No rebase in progress?"));
>  	setenv(GIT_REFLOG_ACTION_ENVIRONMENT, "rebase", 0);
> @@ -1541,12 +1551,22 @@ int cmd_rebase(int argc, const char **argv, const char *prefix)
>  	}
>
>  	/* Make sure the branch to rebase onto is valid. */
> -	if (!options.onto_name)
> +	if (keep_base) {
> +		strbuf_reset(&buf);
> +		strbuf_addstr(&buf, options.upstream_name);
> +		strbuf_addstr(&buf, "...");
> +		options.onto_name = xstrdup(buf.buf);
> +	} else if (!options.onto_name)
>  		options.onto_name = options.upstream_name;
>  	if (strstr(options.onto_name, "...")) {
> -		if (get_oid_mb(options.onto_name, &merge_base) < 0)
> -			die(_("'%s': need exactly one merge base"),
> -			    options.onto_name);
> +		if (get_oid_mb(options.onto_name, &merge_base) < 0) {
> +			if (keep_base)
> +				die(_("'%s': need exactly one merge base with branch"),
> +				    options.upstream_name);
> +			else
> +				die(_("'%s': need exactly one merge base"),
> +				    options.onto_name);
> +		}
>  		options.onto = lookup_commit_or_die(&merge_base,
>  						    options.onto_name);
>  	} else {
> diff --git a/t/t3416-rebase-onto-threedots.sh b/t/t3416-rebase-onto-threedots.sh
> index ddf2f64853..9c2548423b 100755
> --- a/t/t3416-rebase-onto-threedots.sh
> +++ b/t/t3416-rebase-onto-threedots.sh
> @@ -99,7 +99,64 @@ test_expect_success 'rebase -i --onto master...side' '
>  	git checkout side &&
>  	git reset --hard K &&
>
> +	set_fake_editor &&
>  	test_must_fail git rebase -i --onto master...side J
>  '
>
> +test_expect_success 'rebase --keep-base --onto incompatible' '
> +	test_must_fail git rebase --keep-base --onto master...
> +'
> +
> +test_expect_success 'rebase --keep-base --root incompatible' '
> +	test_must_fail git rebase --keep-base --root
> +'
> +
> +test_expect_success 'rebase --keep-base master from topic' '
> +	git reset --hard &&
> +	git checkout topic &&
> +	git reset --hard G &&
> +
> +	git rebase --keep-base master &&
> +	git rev-parse C >base.expect &&
> +	git merge-base master HEAD >base.actual &&
> +	test_cmp base.expect base.actual &&
> +
> +	git rev-parse HEAD~2 >actual &&
> +	git rev-parse C^0 >expect &&
> +	test_cmp expect actual
> +'
> +
> +test_expect_success 'rebase --keep-base master from side' '
> +	git reset --hard &&
> +	git checkout side &&
> +	git reset --hard K &&
> +
> +	test_must_fail git rebase --keep-base master
> +'
> +
> +test_expect_success 'rebase -i --keep-base master from topic' '
> +	git reset --hard &&
> +	git checkout topic &&
> +	git reset --hard G &&
> +
> +	set_fake_editor &&
> +	EXPECT_COUNT=2 git rebase -i --keep-base master &&
> +	git rev-parse C >base.expect &&
> +	git merge-base master HEAD >base.actual &&
> +	test_cmp base.expect base.actual &&
> +
> +	git rev-parse HEAD~2 >actual &&
> +	git rev-parse C^0 >expect &&
> +	test_cmp expect actual
> +'
> +
> +test_expect_success 'rebase -i --keep-base master from side' '
> +	git reset --hard &&
> +	git checkout side &&
> +	git reset --hard K &&
> +
> +	set_fake_editor &&
> +	test_must_fail git rebase -i --keep-base master
> +'
> +
>  test_done
Johannes Schindelin March 29, 2019, 3:47 p.m. UTC | #2
Hi Denton,

On Thu, 28 Mar 2019, Denton Liu wrote:

> A common scenario is if a user is working on a topic branch and they
> wish to make some changes to intermediate commits or autosquash, they
> would run something such as
>
> 	git rebase -i --onto master... master
>
> in order to preserve the merge base. This prevents unnecessary commit
> churning.

Maybe an example would clarify what you try to do here? Something like:

	Example: When contributing a patch series to the Git mailing list,
	one often starts on top of the current `master`. However, while
	developing the patch series, `master` is also developed further,
	and it is sometimes not the best idea to keep rebasing on top
	of `master`, but to keep the base commit as-is. In such a case,
	the user can call

		git rebase -i --onto master... master

	as a shortcut to using the merge base between `master` and the
	current branch as base commit.

I wonder, however, whether it makes sense to introduce a shorter, sweeter
way to do this:

		git rebase -i master...

I.e. if we detect that the `<upstream>` argument is not a valid ref, that
it ends with three dots, and that stripping those dots off makes it a
valid ref, then we internally convert that to the same as `--onto
master... master`.

What do you think?

Ciao,
Dscho

> Alternatively, a user wishing to test individual commits in a topic
> branch without changing anything may run
>
> 	git rebase -x ./test.sh master... master
>
> Since rebasing onto the merge base of the branch and the upstream is
> such a common case, introduce the --keep-base option as a shortcut.
>
> This allows us to rewrite the above as
>
> 	git rebase -i --keep-base master
>
> and
>
> 	git rebase -x ./test.sh --keep-base master
>
> respectively.
>
> Add tests to ensure --keep-base works correctly in the normal case and
> fails when there are multiple merge bases, both in regular and
> interactive mode. Also, test to make sure conflicting options cause
> rebase to fail. While we're adding test cases, add a missing
> set_fake_editor call to 'rebase -i --onto master...side'.
>
> While we're documenting the --keep-base option, change an instance of
> "merge-base" to "merge base", which is the consistent spelling.
>
> Helped-by: Eric Sunshine <sunshine@sunshineco.com>
> Helped-by: Junio C Hamano <gitster@pobox.com>
> Helped-by: Ævar Arnfjörð Bjarmason <avarab@gmail.com>
> Signed-off-by: Denton Liu <liu.denton@gmail.com>
> ---
>
> Ævar, I have a feeling that we're still miscommunicating and we don't
> fully understand each other. I'm putting up v2 to hopefully clear things
> up a little but I welcome more feedback.
>
> This patch now depends "[PATCH 1/8] tests (rebase): spell out the
> `--keep-empty` option" which is the first patch of Johannes's "Do not
> use abbreviated options in tests" patchset[1]. (Thanks for catching
> that, Johannes!)
>
> Changes since v1:
>
> * Squashed old set into one patch
> * Fixed indentation style and dangling else
> * Added more documentation after discussion with Ævar
>
> [1]: https://public-inbox.org/git/a1b4b74b9167e279dae4cd8c58fb28d8a714a66a.1553537656.git.gitgitgadget@gmail.com/
>
>  Documentation/git-rebase.txt     | 25 ++++++++++++--
>  builtin/rebase.c                 | 32 ++++++++++++++----
>  t/t3416-rebase-onto-threedots.sh | 57 ++++++++++++++++++++++++++++++++
>  3 files changed, 105 insertions(+), 9 deletions(-)
>
> diff --git a/Documentation/git-rebase.txt b/Documentation/git-rebase.txt
> index 6363d674b7..27be1f48ff 100644
> --- a/Documentation/git-rebase.txt
> +++ b/Documentation/git-rebase.txt
> @@ -8,8 +8,8 @@ git-rebase - Reapply commits on top of another base tip
>  SYNOPSIS
>  --------
>  [verse]
> -'git rebase' [-i | --interactive] [<options>] [--exec <cmd>] [--onto <newbase>]
> -	[<upstream> [<branch>]]
> +'git rebase' [-i | --interactive] [<options>] [--exec <cmd>]
> +	[--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
> @@ -217,6 +217,19 @@ As a special case, you may use "A\...B" as a shortcut for the
>  merge base of A and B if there is exactly one merge base. You can
>  leave out at most one of A and B, in which case it defaults to HEAD.
>
> +--keep-base::
> +	Set the starting point at which to create the new commits to the
> +	merge base of <upstream> <branch>. Running
> +	'git rebase --keep-base <upstream> <branch>' is equivalent to
> +	running 'git rebase --onto <upstream>... <upstream>'.
> ++
> +Although both this option and --fork-point find the merge base between
> +<upstream> and <branch>, this option uses the merge base as the _starting
> +point_ on which new commits will be created, whereas --fork-point uses
> +the merge base to determine the _set of commits_ which will be rebased.
> ++
> +See also INCOMPATIBLE OPTIONS below.
> +
>  <upstream>::
>  	Upstream branch to compare against.  May be any valid commit,
>  	not just an existing branch name. Defaults to the configured
> @@ -364,6 +377,10 @@ ends up being empty, the <upstream> will be used as a fallback.
>  +
>  If either <upstream> or --root is given on the command line, then the
>  default is `--no-fork-point`, otherwise the default is `--fork-point`.
> ++
> +If your branch was based on <upstream> but <upstream> was rewound and
> +your branch contains commits which were dropped, this option can be used
> +with `--keep-base` in order to drop those commits from your branch.
>
>  --ignore-whitespace::
>  --whitespace=<option>::
> @@ -539,6 +556,8 @@ In addition, the following pairs of options are incompatible:
>   * --preserve-merges and --rebase-merges
>   * --rebase-merges and --strategy
>   * --rebase-merges and --strategy-option
> + * --keep-base and --onto
> + * --keep-base and --root
>
>  BEHAVIORAL DIFFERENCES
>  -----------------------
> @@ -863,7 +882,7 @@ NOTE: While an "easy case recovery" sometimes appears to be successful
>        --interactive` will be **resurrected**!
>
>  The idea is to manually tell 'git rebase' "where the old 'subsystem'
> -ended and your 'topic' began", that is, what the old merge-base
> +ended and your 'topic' began", that is, what the old merge base
>  between them was.  You will have to find a way to name the last commit
>  of the old 'subsystem', for example:
>
> diff --git a/builtin/rebase.c b/builtin/rebase.c
> index 77deebc65c..7c14a00460 100644
> --- a/builtin/rebase.c
> +++ b/builtin/rebase.c
> @@ -27,8 +27,8 @@
>  #include "branch.h"
>
>  static char const * const builtin_rebase_usage[] = {
> -	N_("git rebase [-i] [options] [--exec <cmd>] [--onto <newbase>] "
> -		"[<upstream>] [<branch>]"),
> +	N_("git rebase [-i] [options] [--exec <cmd>] "
> +		"[--onto <newbase> | --keep-base] [<upstream> [<branch>]]"),
>  	N_("git rebase [-i] [options] [--exec <cmd>] [--onto <newbase>] "
>  		"--root [<branch>]"),
>  	N_("git rebase --continue | --abort | --skip | --edit-todo"),
> @@ -1018,6 +1018,7 @@ int cmd_rebase(int argc, const char **argv, const char *prefix)
>  	};
>  	const char *branch_name;
>  	int ret, flags, total_argc, in_progress = 0;
> +	int keep_base = 0;
>  	int ok_to_skip_pre_rebase = 0;
>  	struct strbuf msg = STRBUF_INIT;
>  	struct strbuf revisions = STRBUF_INIT;
> @@ -1051,6 +1052,8 @@ int cmd_rebase(int argc, const char **argv, const char *prefix)
>  		OPT_STRING(0, "onto", &options.onto_name,
>  			   N_("revision"),
>  			   N_("rebase onto given branch instead of upstream")),
> +		OPT_BOOL(0, "keep-base", &keep_base,
> +			 N_("use the merge-base of upstream and branch as the current base")),
>  		OPT_BOOL(0, "no-verify", &ok_to_skip_pre_rebase,
>  			 N_("allow pre-rebase hook to run")),
>  		OPT_NEGBIT('q', "quiet", &options.flags,
> @@ -1217,6 +1220,13 @@ int cmd_rebase(int argc, const char **argv, const char *prefix)
>  		usage_with_options(builtin_rebase_usage,
>  				   builtin_rebase_options);
>
> +	if (keep_base) {
> +		if (options.onto_name)
> +			die(_("cannot combine '--keep-base' with '--onto'"));
> +		if (options.root)
> +			die(_("cannot combine '--keep-base' with '--root'"));
> +	}
> +
>  	if (action != NO_ACTION && !in_progress)
>  		die(_("No rebase in progress?"));
>  	setenv(GIT_REFLOG_ACTION_ENVIRONMENT, "rebase", 0);
> @@ -1541,12 +1551,22 @@ int cmd_rebase(int argc, const char **argv, const char *prefix)
>  	}
>
>  	/* Make sure the branch to rebase onto is valid. */
> -	if (!options.onto_name)
> +	if (keep_base) {
> +		strbuf_reset(&buf);
> +		strbuf_addstr(&buf, options.upstream_name);
> +		strbuf_addstr(&buf, "...");
> +		options.onto_name = xstrdup(buf.buf);
> +	} else if (!options.onto_name)
>  		options.onto_name = options.upstream_name;
>  	if (strstr(options.onto_name, "...")) {
> -		if (get_oid_mb(options.onto_name, &merge_base) < 0)
> -			die(_("'%s': need exactly one merge base"),
> -			    options.onto_name);
> +		if (get_oid_mb(options.onto_name, &merge_base) < 0) {
> +			if (keep_base)
> +				die(_("'%s': need exactly one merge base with branch"),
> +				    options.upstream_name);
> +			else
> +				die(_("'%s': need exactly one merge base"),
> +				    options.onto_name);
> +		}
>  		options.onto = lookup_commit_or_die(&merge_base,
>  						    options.onto_name);
>  	} else {
> diff --git a/t/t3416-rebase-onto-threedots.sh b/t/t3416-rebase-onto-threedots.sh
> index ddf2f64853..9c2548423b 100755
> --- a/t/t3416-rebase-onto-threedots.sh
> +++ b/t/t3416-rebase-onto-threedots.sh
> @@ -99,7 +99,64 @@ test_expect_success 'rebase -i --onto master...side' '
>  	git checkout side &&
>  	git reset --hard K &&
>
> +	set_fake_editor &&
>  	test_must_fail git rebase -i --onto master...side J
>  '
>
> +test_expect_success 'rebase --keep-base --onto incompatible' '
> +	test_must_fail git rebase --keep-base --onto master...
> +'
> +
> +test_expect_success 'rebase --keep-base --root incompatible' '
> +	test_must_fail git rebase --keep-base --root
> +'
> +
> +test_expect_success 'rebase --keep-base master from topic' '
> +	git reset --hard &&
> +	git checkout topic &&
> +	git reset --hard G &&
> +
> +	git rebase --keep-base master &&
> +	git rev-parse C >base.expect &&
> +	git merge-base master HEAD >base.actual &&
> +	test_cmp base.expect base.actual &&
> +
> +	git rev-parse HEAD~2 >actual &&
> +	git rev-parse C^0 >expect &&
> +	test_cmp expect actual
> +'
> +
> +test_expect_success 'rebase --keep-base master from side' '
> +	git reset --hard &&
> +	git checkout side &&
> +	git reset --hard K &&
> +
> +	test_must_fail git rebase --keep-base master
> +'
> +
> +test_expect_success 'rebase -i --keep-base master from topic' '
> +	git reset --hard &&
> +	git checkout topic &&
> +	git reset --hard G &&
> +
> +	set_fake_editor &&
> +	EXPECT_COUNT=2 git rebase -i --keep-base master &&
> +	git rev-parse C >base.expect &&
> +	git merge-base master HEAD >base.actual &&
> +	test_cmp base.expect base.actual &&
> +
> +	git rev-parse HEAD~2 >actual &&
> +	git rev-parse C^0 >expect &&
> +	test_cmp expect actual
> +'
> +
> +test_expect_success 'rebase -i --keep-base master from side' '
> +	git reset --hard &&
> +	git checkout side &&
> +	git reset --hard K &&
> +
> +	set_fake_editor &&
> +	test_must_fail git rebase -i --keep-base master
> +'
> +
>  test_done
> --
> 2.21.0.695.gaf8658f249
>
>
Denton Liu March 29, 2019, 5:52 p.m. UTC | #3
Hi Johannes,

On Fri, Mar 29, 2019 at 04:47:42PM +0100, Johannes Schindelin wrote:
> Hi Denton,
> 
> On Thu, 28 Mar 2019, Denton Liu wrote:
> 
> > A common scenario is if a user is working on a topic branch and they
> > wish to make some changes to intermediate commits or autosquash, they
> > would run something such as
> >
> > 	git rebase -i --onto master... master
> >
> > in order to preserve the merge base. This prevents unnecessary commit
> > churning.
> 
> Maybe an example would clarify what you try to do here? Something like:
> 
> 	Example: When contributing a patch series to the Git mailing list,
> 	one often starts on top of the current `master`. However, while
> 	developing the patch series, `master` is also developed further,
> 	and it is sometimes not the best idea to keep rebasing on top
> 	of `master`, but to keep the base commit as-is. In such a case,
> 	the user can call
> 
> 		git rebase -i --onto master... master
> 
> 	as a shortcut to using the merge base between `master` and the
> 	current branch as base commit.

Will do, I'll include an example in the next reroll (probably in the
docs too).

> 
> I wonder, however, whether it makes sense to introduce a shorter, sweeter
> way to do this:
> 
> 		git rebase -i master...
> 
> I.e. if we detect that the `<upstream>` argument is not a valid ref, that
> it ends with three dots, and that stripping those dots off makes it a
> valid ref, then we internally convert that to the same as `--onto
> master... master`.

There's one use-case that this syntax wouldn't cover. Currently, if
<upstream> isn't specified, rebase automatically uses 
'git merge-base --fork-point @{u} HEAD' as <upstream>. This is
even true for an invocation of 'git rebase --keep-base'.

As a result, suppose we have the following graph

	o - f - o - U' origin/master
	    \
	     U - A master

where U used to be in origin/master (when we forked off) but upstream
was rewound to drop it. If we run 'git rebase --keep-base', we'll get
the following graph:

	o - f - o - U' origin/master
	    \
	     A master

i.e. we'll drop the commit dropped by upstream.

I'm not entirely sure how useful this is, though, so if we decide to
drop support for this use-case, then your proposed syntax will be fine.

Thanks,

Denton

> 
> What do you think?
> 
> Ciao,
> Dscho
> 
> > Alternatively, a user wishing to test individual commits in a topic
> > branch without changing anything may run
> >
> > 	git rebase -x ./test.sh master... master
> >
> > Since rebasing onto the merge base of the branch and the upstream is
> > such a common case, introduce the --keep-base option as a shortcut.
> >
> > This allows us to rewrite the above as
> >
> > 	git rebase -i --keep-base master
> >
> > and
> >
> > 	git rebase -x ./test.sh --keep-base master
> >
> > respectively.
> >
> > Add tests to ensure --keep-base works correctly in the normal case and
> > fails when there are multiple merge bases, both in regular and
> > interactive mode. Also, test to make sure conflicting options cause
> > rebase to fail. While we're adding test cases, add a missing
> > set_fake_editor call to 'rebase -i --onto master...side'.
> >
> > While we're documenting the --keep-base option, change an instance of
> > "merge-base" to "merge base", which is the consistent spelling.
> >
> > Helped-by: Eric Sunshine <sunshine@sunshineco.com>
> > Helped-by: Junio C Hamano <gitster@pobox.com>
> > Helped-by: Ævar Arnfjörð Bjarmason <avarab@gmail.com>
> > Signed-off-by: Denton Liu <liu.denton@gmail.com>
> > ---
> >
> > Ævar, I have a feeling that we're still miscommunicating and we don't
> > fully understand each other. I'm putting up v2 to hopefully clear things
> > up a little but I welcome more feedback.
> >
> > This patch now depends "[PATCH 1/8] tests (rebase): spell out the
> > `--keep-empty` option" which is the first patch of Johannes's "Do not
> > use abbreviated options in tests" patchset[1]. (Thanks for catching
> > that, Johannes!)
> >
> > Changes since v1:
> >
> > * Squashed old set into one patch
> > * Fixed indentation style and dangling else
> > * Added more documentation after discussion with Ævar
> >
> > [1]: https://public-inbox.org/git/a1b4b74b9167e279dae4cd8c58fb28d8a714a66a.1553537656.git.gitgitgadget@gmail.com/
> >
> >  Documentation/git-rebase.txt     | 25 ++++++++++++--
> >  builtin/rebase.c                 | 32 ++++++++++++++----
> >  t/t3416-rebase-onto-threedots.sh | 57 ++++++++++++++++++++++++++++++++
> >  3 files changed, 105 insertions(+), 9 deletions(-)
> >
> > diff --git a/Documentation/git-rebase.txt b/Documentation/git-rebase.txt
> > index 6363d674b7..27be1f48ff 100644
> > --- a/Documentation/git-rebase.txt
> > +++ b/Documentation/git-rebase.txt
> > @@ -8,8 +8,8 @@ git-rebase - Reapply commits on top of another base tip
> >  SYNOPSIS
> >  --------
> >  [verse]
> > -'git rebase' [-i | --interactive] [<options>] [--exec <cmd>] [--onto <newbase>]
> > -	[<upstream> [<branch>]]
> > +'git rebase' [-i | --interactive] [<options>] [--exec <cmd>]
> > +	[--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
> > @@ -217,6 +217,19 @@ As a special case, you may use "A\...B" as a shortcut for the
> >  merge base of A and B if there is exactly one merge base. You can
> >  leave out at most one of A and B, in which case it defaults to HEAD.
> >
> > +--keep-base::
> > +	Set the starting point at which to create the new commits to the
> > +	merge base of <upstream> <branch>. Running
> > +	'git rebase --keep-base <upstream> <branch>' is equivalent to
> > +	running 'git rebase --onto <upstream>... <upstream>'.
> > ++
> > +Although both this option and --fork-point find the merge base between
> > +<upstream> and <branch>, this option uses the merge base as the _starting
> > +point_ on which new commits will be created, whereas --fork-point uses
> > +the merge base to determine the _set of commits_ which will be rebased.
> > ++
> > +See also INCOMPATIBLE OPTIONS below.
> > +
> >  <upstream>::
> >  	Upstream branch to compare against.  May be any valid commit,
> >  	not just an existing branch name. Defaults to the configured
> > @@ -364,6 +377,10 @@ ends up being empty, the <upstream> will be used as a fallback.
> >  +
> >  If either <upstream> or --root is given on the command line, then the
> >  default is `--no-fork-point`, otherwise the default is `--fork-point`.
> > ++
> > +If your branch was based on <upstream> but <upstream> was rewound and
> > +your branch contains commits which were dropped, this option can be used
> > +with `--keep-base` in order to drop those commits from your branch.
> >
> >  --ignore-whitespace::
> >  --whitespace=<option>::
> > @@ -539,6 +556,8 @@ In addition, the following pairs of options are incompatible:
> >   * --preserve-merges and --rebase-merges
> >   * --rebase-merges and --strategy
> >   * --rebase-merges and --strategy-option
> > + * --keep-base and --onto
> > + * --keep-base and --root
> >
> >  BEHAVIORAL DIFFERENCES
> >  -----------------------
> > @@ -863,7 +882,7 @@ NOTE: While an "easy case recovery" sometimes appears to be successful
> >        --interactive` will be **resurrected**!
> >
> >  The idea is to manually tell 'git rebase' "where the old 'subsystem'
> > -ended and your 'topic' began", that is, what the old merge-base
> > +ended and your 'topic' began", that is, what the old merge base
> >  between them was.  You will have to find a way to name the last commit
> >  of the old 'subsystem', for example:
> >
> > diff --git a/builtin/rebase.c b/builtin/rebase.c
> > index 77deebc65c..7c14a00460 100644
> > --- a/builtin/rebase.c
> > +++ b/builtin/rebase.c
> > @@ -27,8 +27,8 @@
> >  #include "branch.h"
> >
> >  static char const * const builtin_rebase_usage[] = {
> > -	N_("git rebase [-i] [options] [--exec <cmd>] [--onto <newbase>] "
> > -		"[<upstream>] [<branch>]"),
> > +	N_("git rebase [-i] [options] [--exec <cmd>] "
> > +		"[--onto <newbase> | --keep-base] [<upstream> [<branch>]]"),
> >  	N_("git rebase [-i] [options] [--exec <cmd>] [--onto <newbase>] "
> >  		"--root [<branch>]"),
> >  	N_("git rebase --continue | --abort | --skip | --edit-todo"),
> > @@ -1018,6 +1018,7 @@ int cmd_rebase(int argc, const char **argv, const char *prefix)
> >  	};
> >  	const char *branch_name;
> >  	int ret, flags, total_argc, in_progress = 0;
> > +	int keep_base = 0;
> >  	int ok_to_skip_pre_rebase = 0;
> >  	struct strbuf msg = STRBUF_INIT;
> >  	struct strbuf revisions = STRBUF_INIT;
> > @@ -1051,6 +1052,8 @@ int cmd_rebase(int argc, const char **argv, const char *prefix)
> >  		OPT_STRING(0, "onto", &options.onto_name,
> >  			   N_("revision"),
> >  			   N_("rebase onto given branch instead of upstream")),
> > +		OPT_BOOL(0, "keep-base", &keep_base,
> > +			 N_("use the merge-base of upstream and branch as the current base")),
> >  		OPT_BOOL(0, "no-verify", &ok_to_skip_pre_rebase,
> >  			 N_("allow pre-rebase hook to run")),
> >  		OPT_NEGBIT('q', "quiet", &options.flags,
> > @@ -1217,6 +1220,13 @@ int cmd_rebase(int argc, const char **argv, const char *prefix)
> >  		usage_with_options(builtin_rebase_usage,
> >  				   builtin_rebase_options);
> >
> > +	if (keep_base) {
> > +		if (options.onto_name)
> > +			die(_("cannot combine '--keep-base' with '--onto'"));
> > +		if (options.root)
> > +			die(_("cannot combine '--keep-base' with '--root'"));
> > +	}
> > +
> >  	if (action != NO_ACTION && !in_progress)
> >  		die(_("No rebase in progress?"));
> >  	setenv(GIT_REFLOG_ACTION_ENVIRONMENT, "rebase", 0);
> > @@ -1541,12 +1551,22 @@ int cmd_rebase(int argc, const char **argv, const char *prefix)
> >  	}
> >
> >  	/* Make sure the branch to rebase onto is valid. */
> > -	if (!options.onto_name)
> > +	if (keep_base) {
> > +		strbuf_reset(&buf);
> > +		strbuf_addstr(&buf, options.upstream_name);
> > +		strbuf_addstr(&buf, "...");
> > +		options.onto_name = xstrdup(buf.buf);
> > +	} else if (!options.onto_name)
> >  		options.onto_name = options.upstream_name;
> >  	if (strstr(options.onto_name, "...")) {
> > -		if (get_oid_mb(options.onto_name, &merge_base) < 0)
> > -			die(_("'%s': need exactly one merge base"),
> > -			    options.onto_name);
> > +		if (get_oid_mb(options.onto_name, &merge_base) < 0) {
> > +			if (keep_base)
> > +				die(_("'%s': need exactly one merge base with branch"),
> > +				    options.upstream_name);
> > +			else
> > +				die(_("'%s': need exactly one merge base"),
> > +				    options.onto_name);
> > +		}
> >  		options.onto = lookup_commit_or_die(&merge_base,
> >  						    options.onto_name);
> >  	} else {
> > diff --git a/t/t3416-rebase-onto-threedots.sh b/t/t3416-rebase-onto-threedots.sh
> > index ddf2f64853..9c2548423b 100755
> > --- a/t/t3416-rebase-onto-threedots.sh
> > +++ b/t/t3416-rebase-onto-threedots.sh
> > @@ -99,7 +99,64 @@ test_expect_success 'rebase -i --onto master...side' '
> >  	git checkout side &&
> >  	git reset --hard K &&
> >
> > +	set_fake_editor &&
> >  	test_must_fail git rebase -i --onto master...side J
> >  '
> >
> > +test_expect_success 'rebase --keep-base --onto incompatible' '
> > +	test_must_fail git rebase --keep-base --onto master...
> > +'
> > +
> > +test_expect_success 'rebase --keep-base --root incompatible' '
> > +	test_must_fail git rebase --keep-base --root
> > +'
> > +
> > +test_expect_success 'rebase --keep-base master from topic' '
> > +	git reset --hard &&
> > +	git checkout topic &&
> > +	git reset --hard G &&
> > +
> > +	git rebase --keep-base master &&
> > +	git rev-parse C >base.expect &&
> > +	git merge-base master HEAD >base.actual &&
> > +	test_cmp base.expect base.actual &&
> > +
> > +	git rev-parse HEAD~2 >actual &&
> > +	git rev-parse C^0 >expect &&
> > +	test_cmp expect actual
> > +'
> > +
> > +test_expect_success 'rebase --keep-base master from side' '
> > +	git reset --hard &&
> > +	git checkout side &&
> > +	git reset --hard K &&
> > +
> > +	test_must_fail git rebase --keep-base master
> > +'
> > +
> > +test_expect_success 'rebase -i --keep-base master from topic' '
> > +	git reset --hard &&
> > +	git checkout topic &&
> > +	git reset --hard G &&
> > +
> > +	set_fake_editor &&
> > +	EXPECT_COUNT=2 git rebase -i --keep-base master &&
> > +	git rev-parse C >base.expect &&
> > +	git merge-base master HEAD >base.actual &&
> > +	test_cmp base.expect base.actual &&
> > +
> > +	git rev-parse HEAD~2 >actual &&
> > +	git rev-parse C^0 >expect &&
> > +	test_cmp expect actual
> > +'
> > +
> > +test_expect_success 'rebase -i --keep-base master from side' '
> > +	git reset --hard &&
> > +	git checkout side &&
> > +	git reset --hard K &&
> > +
> > +	set_fake_editor &&
> > +	test_must_fail git rebase -i --keep-base master
> > +'
> > +
> >  test_done
> > --
> > 2.21.0.695.gaf8658f249
> >
> >
Junio C Hamano April 1, 2019, 10:46 a.m. UTC | #4
Johannes Schindelin <Johannes.Schindelin@gmx.de> writes:

> Hi Denton,
>
> On Thu, 28 Mar 2019, Denton Liu wrote:
>
>> A common scenario is if a user is working on a topic branch and they
>> wish to make some changes to intermediate commits or autosquash, they
>> would run something such as
>>
>> 	git rebase -i --onto master... master
> ...
> I wonder, however, whether it makes sense to introduce a shorter, sweeter
> way to do this:
>
> 		git rebase -i master...

I agree that this is very tempting, as it mimicks "git checkout
master...".  But as Denton responds, it is not quite the same, so...
diff mbox series

Patch

diff --git a/Documentation/git-rebase.txt b/Documentation/git-rebase.txt
index 6363d674b7..27be1f48ff 100644
--- a/Documentation/git-rebase.txt
+++ b/Documentation/git-rebase.txt
@@ -8,8 +8,8 @@  git-rebase - Reapply commits on top of another base tip
 SYNOPSIS
 --------
 [verse]
-'git rebase' [-i | --interactive] [<options>] [--exec <cmd>] [--onto <newbase>]
-	[<upstream> [<branch>]]
+'git rebase' [-i | --interactive] [<options>] [--exec <cmd>]
+	[--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
@@ -217,6 +217,19 @@  As a special case, you may use "A\...B" as a shortcut for the
 merge base of A and B if there is exactly one merge base. You can
 leave out at most one of A and B, in which case it defaults to HEAD.
 
+--keep-base::
+	Set the starting point at which to create the new commits to the
+	merge base of <upstream> <branch>. Running
+	'git rebase --keep-base <upstream> <branch>' is equivalent to
+	running 'git rebase --onto <upstream>... <upstream>'.
++
+Although both this option and --fork-point find the merge base between
+<upstream> and <branch>, this option uses the merge base as the _starting
+point_ on which new commits will be created, whereas --fork-point uses
+the merge base to determine the _set of commits_ which will be rebased.
++
+See also INCOMPATIBLE OPTIONS below.
+
 <upstream>::
 	Upstream branch to compare against.  May be any valid commit,
 	not just an existing branch name. Defaults to the configured
@@ -364,6 +377,10 @@  ends up being empty, the <upstream> will be used as a fallback.
 +
 If either <upstream> or --root is given on the command line, then the
 default is `--no-fork-point`, otherwise the default is `--fork-point`.
++
+If your branch was based on <upstream> but <upstream> was rewound and
+your branch contains commits which were dropped, this option can be used
+with `--keep-base` in order to drop those commits from your branch.
 
 --ignore-whitespace::
 --whitespace=<option>::
@@ -539,6 +556,8 @@  In addition, the following pairs of options are incompatible:
  * --preserve-merges and --rebase-merges
  * --rebase-merges and --strategy
  * --rebase-merges and --strategy-option
+ * --keep-base and --onto
+ * --keep-base and --root
 
 BEHAVIORAL DIFFERENCES
 -----------------------
@@ -863,7 +882,7 @@  NOTE: While an "easy case recovery" sometimes appears to be successful
       --interactive` will be **resurrected**!
 
 The idea is to manually tell 'git rebase' "where the old 'subsystem'
-ended and your 'topic' began", that is, what the old merge-base
+ended and your 'topic' began", that is, what the old merge base
 between them was.  You will have to find a way to name the last commit
 of the old 'subsystem', for example:
 
diff --git a/builtin/rebase.c b/builtin/rebase.c
index 77deebc65c..7c14a00460 100644
--- a/builtin/rebase.c
+++ b/builtin/rebase.c
@@ -27,8 +27,8 @@ 
 #include "branch.h"
 
 static char const * const builtin_rebase_usage[] = {
-	N_("git rebase [-i] [options] [--exec <cmd>] [--onto <newbase>] "
-		"[<upstream>] [<branch>]"),
+	N_("git rebase [-i] [options] [--exec <cmd>] "
+		"[--onto <newbase> | --keep-base] [<upstream> [<branch>]]"),
 	N_("git rebase [-i] [options] [--exec <cmd>] [--onto <newbase>] "
 		"--root [<branch>]"),
 	N_("git rebase --continue | --abort | --skip | --edit-todo"),
@@ -1018,6 +1018,7 @@  int cmd_rebase(int argc, const char **argv, const char *prefix)
 	};
 	const char *branch_name;
 	int ret, flags, total_argc, in_progress = 0;
+	int keep_base = 0;
 	int ok_to_skip_pre_rebase = 0;
 	struct strbuf msg = STRBUF_INIT;
 	struct strbuf revisions = STRBUF_INIT;
@@ -1051,6 +1052,8 @@  int cmd_rebase(int argc, const char **argv, const char *prefix)
 		OPT_STRING(0, "onto", &options.onto_name,
 			   N_("revision"),
 			   N_("rebase onto given branch instead of upstream")),
+		OPT_BOOL(0, "keep-base", &keep_base,
+			 N_("use the merge-base of upstream and branch as the current base")),
 		OPT_BOOL(0, "no-verify", &ok_to_skip_pre_rebase,
 			 N_("allow pre-rebase hook to run")),
 		OPT_NEGBIT('q', "quiet", &options.flags,
@@ -1217,6 +1220,13 @@  int cmd_rebase(int argc, const char **argv, const char *prefix)
 		usage_with_options(builtin_rebase_usage,
 				   builtin_rebase_options);
 
+	if (keep_base) {
+		if (options.onto_name)
+			die(_("cannot combine '--keep-base' with '--onto'"));
+		if (options.root)
+			die(_("cannot combine '--keep-base' with '--root'"));
+	}
+
 	if (action != NO_ACTION && !in_progress)
 		die(_("No rebase in progress?"));
 	setenv(GIT_REFLOG_ACTION_ENVIRONMENT, "rebase", 0);
@@ -1541,12 +1551,22 @@  int cmd_rebase(int argc, const char **argv, const char *prefix)
 	}
 
 	/* Make sure the branch to rebase onto is valid. */
-	if (!options.onto_name)
+	if (keep_base) {
+		strbuf_reset(&buf);
+		strbuf_addstr(&buf, options.upstream_name);
+		strbuf_addstr(&buf, "...");
+		options.onto_name = xstrdup(buf.buf);
+	} else if (!options.onto_name)
 		options.onto_name = options.upstream_name;
 	if (strstr(options.onto_name, "...")) {
-		if (get_oid_mb(options.onto_name, &merge_base) < 0)
-			die(_("'%s': need exactly one merge base"),
-			    options.onto_name);
+		if (get_oid_mb(options.onto_name, &merge_base) < 0) {
+			if (keep_base)
+				die(_("'%s': need exactly one merge base with branch"),
+				    options.upstream_name);
+			else
+				die(_("'%s': need exactly one merge base"),
+				    options.onto_name);
+		}
 		options.onto = lookup_commit_or_die(&merge_base,
 						    options.onto_name);
 	} else {
diff --git a/t/t3416-rebase-onto-threedots.sh b/t/t3416-rebase-onto-threedots.sh
index ddf2f64853..9c2548423b 100755
--- a/t/t3416-rebase-onto-threedots.sh
+++ b/t/t3416-rebase-onto-threedots.sh
@@ -99,7 +99,64 @@  test_expect_success 'rebase -i --onto master...side' '
 	git checkout side &&
 	git reset --hard K &&
 
+	set_fake_editor &&
 	test_must_fail git rebase -i --onto master...side J
 '
 
+test_expect_success 'rebase --keep-base --onto incompatible' '
+	test_must_fail git rebase --keep-base --onto master...
+'
+
+test_expect_success 'rebase --keep-base --root incompatible' '
+	test_must_fail git rebase --keep-base --root
+'
+
+test_expect_success 'rebase --keep-base master from topic' '
+	git reset --hard &&
+	git checkout topic &&
+	git reset --hard G &&
+
+	git rebase --keep-base master &&
+	git rev-parse C >base.expect &&
+	git merge-base master HEAD >base.actual &&
+	test_cmp base.expect base.actual &&
+
+	git rev-parse HEAD~2 >actual &&
+	git rev-parse C^0 >expect &&
+	test_cmp expect actual
+'
+
+test_expect_success 'rebase --keep-base master from side' '
+	git reset --hard &&
+	git checkout side &&
+	git reset --hard K &&
+
+	test_must_fail git rebase --keep-base master
+'
+
+test_expect_success 'rebase -i --keep-base master from topic' '
+	git reset --hard &&
+	git checkout topic &&
+	git reset --hard G &&
+
+	set_fake_editor &&
+	EXPECT_COUNT=2 git rebase -i --keep-base master &&
+	git rev-parse C >base.expect &&
+	git merge-base master HEAD >base.actual &&
+	test_cmp base.expect base.actual &&
+
+	git rev-parse HEAD~2 >actual &&
+	git rev-parse C^0 >expect &&
+	test_cmp expect actual
+'
+
+test_expect_success 'rebase -i --keep-base master from side' '
+	git reset --hard &&
+	git checkout side &&
+	git reset --hard K &&
+
+	set_fake_editor &&
+	test_must_fail git rebase -i --keep-base master
+'
+
 test_done