Merge branch 'hn/status-push-rebased-on-newer-upstream' into seen

The 'git status' output and 'git push' rejection advice have been
improved to recognize when a pushed branch was cleanly rebased onto
a newer upstream, and now correctly suggest force-pushing instead of
pulling.

* hn/status-push-rebased-on-newer-upstream:
  push: suggest a force push after a clean rebase
  push: offer a force push after rewriting pushed commits
  push: name the branch to pull from when it is not the upstream
  status: suggest a force push after a clean rebase
  status: say when the push branch was rebased cleanly
  status: count push divergence outside the upstream
seen
Junio C Hamano 2026-10-12 09:26:14 -07:00
commit a7083008cd
6 changed files with 387 additions and 19 deletions

View File

@ -12,6 +12,7 @@
#include "environment.h"
#include "gettext.h"
#include "hex.h"
#include "refs.h"
#include "refspec.h"
#include "run-command.h"
#include "remote.h"
@ -295,6 +296,22 @@ static const char message_advice_pull_before_push[] =
"use 'git pull' before pushing again.\n"
"See the 'Note about fast-forwards' in 'git push --help' for details.");

static const char message_advice_pull_from_branch_before_push[] =
N_("Updates were rejected because '%s' has diverged\n"
"from your current branch. Use 'git pull %s %s'\n"
"to integrate the remote changes.");

static const char message_advice_force_after_clean_rebase[] =
N_("Updates were rejected because '%s' has diverged\n"
"from your current branch, which was rebased cleanly on '%s'.\n"
"Use 'git push --force-with-lease %s %s' to replace it.");

static const char message_advice_pull_or_force_before_push[] =
N_("Updates were rejected because '%s' has diverged\n"
"from your current branch. Use 'git pull %s %s'\n"
"to integrate the remote changes, or replace them with\n"
"'git push --force-with-lease %s %s'.");

static const char message_advice_checkout_pull_push[] =
N_("Updates were rejected because a pushed branch tip is behind its remote\n"
"counterpart. If you want to integrate the remote changes, use 'git pull'\n"
@ -329,11 +346,49 @@ static const char message_advice_ref_unverifiable[] =
"'--force-with-lease=<ref>:<expect>' or use '--no-force-if-includes'\n"
"to skip this check.");

static void advise_pull_before_push(void)
static void advise_pull_before_push(struct remote *push_remote,
unsigned int reject_reasons)
{
struct branch *branch = branch_get(NULL);
struct remote *remote = NULL;
const char *upstream = NULL;
char *tracking = NULL;
char *tracking_name = NULL;

if (!advice_enabled(ADVICE_PUSH_NON_FF_CURRENT) || !advice_enabled(ADVICE_PUSH_UPDATE_REJECTED))
return;
advise(_(message_advice_pull_before_push));

if (branch) {
remote = repo_remote_for_push_tracking(the_repository,
push_remote);
tracking = apply_refspecs(&remote->fetch, branch->refname);
upstream = branch_get_upstream(branch, NULL);
}
if (tracking)
tracking_name = refs_shorten_unambiguous_ref(
get_main_ref_store(the_repository), tracking, 0);

if (tracking && branch_rebased_cleanly(branch, tracking)) {
char *upstream_name = refs_shorten_unambiguous_ref(
get_main_ref_store(the_repository), upstream, 0);

advise(_(message_advice_force_after_clean_rebase),
tracking_name, upstream_name,
remote->name, branch->name);
free(upstream_name);
} else if (tracking && (reject_reasons & REJECT_NON_FF_HEAD_REWRITE)) {
advise(_(message_advice_pull_or_force_before_push),
tracking_name, remote->name, branch->name,
remote->name, branch->name);
} else if (tracking && (!upstream || strcmp(tracking, upstream))) {
advise(_(message_advice_pull_from_branch_before_push),
tracking_name, remote->name, branch->name);
} else {
advise(_(message_advice_pull_before_push));
}

free(tracking_name);
free(tracking);
}

static void advise_checkout_pull_push(void)
@ -385,6 +440,7 @@ static int push_with_options(struct transport *transport, struct refspec *rs,
int err;
unsigned int reject_reasons;
char *anon_url = transport_anonymize_url(transport->url);
struct remote *remote = transport->remote;

transport_set_verbosity(transport, verbosity, progress);
transport->family = family;
@ -419,7 +475,7 @@ static int push_with_options(struct transport *transport, struct refspec *rs,
return 0;

if (reject_reasons & REJECT_NON_FF_HEAD) {
advise_pull_before_push();
advise_pull_before_push(remote, reject_reasons);
} else if (reject_reasons & REJECT_NON_FF_OTHER) {
advise_checkout_pull_push();
} else if (reject_reasons & REJECT_ALREADY_EXISTS) {

132
remote.c
View File

@ -2267,11 +2267,12 @@ int resolve_remote_symref(struct ref *ref, struct ref *list)
*/

static int stat_branch_pair(const char *branch_name, const char *base,
int *num_ours, int *num_theirs,
const char *exclude,
int *num_ours, int *num_theirs, int *num_same,
enum ahead_behind_flags abf)
{
struct object_id oid;
struct commit *ours, *theirs;
struct commit *ours, *theirs, *excluded = NULL;
struct rev_info revs;
struct strvec argv = STRVEC_INIT;

@ -2288,7 +2289,17 @@ static int stat_branch_pair(const char *branch_name, const char *base,
if (!ours)
return -1;

if (exclude) {
if (refs_read_ref(get_main_ref_store(the_repository), exclude, &oid))
return -1;
excluded = lookup_commit_reference(the_repository, &oid);
if (!excluded)
return -1;
}

*num_theirs = *num_ours = 0;
if (num_same)
*num_same = 0;

/* are we the same? */
if (theirs == ours)
@ -2301,9 +2312,13 @@ static int stat_branch_pair(const char *branch_name, const char *base,
/* Run "rev-list --left-right ours...theirs" internally... */
strvec_push(&argv, ""); /* ignored */
strvec_push(&argv, "--left-right");
if (num_same)
strvec_push(&argv, "--cherry-mark");
strvec_pushf(&argv, "%s...%s",
oid_to_hex(&ours->object.oid),
oid_to_hex(&theirs->object.oid));
if (excluded)
strvec_pushf(&argv, "^%s", oid_to_hex(&excluded->object.oid));
strvec_push(&argv, "--");

repo_init_revisions(the_repository, &revs, NULL);
@ -2320,11 +2335,15 @@ static int stat_branch_pair(const char *branch_name, const char *base,
(*num_ours)++;
else
(*num_theirs)++;
if (num_same && (c->object.flags & PATCHSAME))
(*num_same)++;
}

/* clear object flags smudged by the above traversal */
clear_commit_marks(ours, ALL_REV_FLAGS);
clear_commit_marks(theirs, ALL_REV_FLAGS);
if (excluded)
clear_commit_marks(excluded, ALL_REV_FLAGS);

strvec_clear(&argv);
release_revisions(&revs);
@ -2364,7 +2383,49 @@ int stat_tracking_info(struct branch *branch, int *num_ours, int *num_theirs,
if (!base)
return -1;

return stat_branch_pair(branch->refname, base, num_ours, num_theirs, abf);
return stat_branch_pair(branch->refname, base, NULL,
num_ours, num_theirs, NULL, abf);
}

/*
* Count the commits that differ between branch_name and base but are not
* in upstream. Return false when they cannot be counted or upstream
* accounts for none of the ours and theirs commits. Otherwise set
* *same_changes when the remaining commits carry the same changes on
* both sides.
*/
static bool stat_outside_upstream(const char *branch_name, const char *base,
const char *upstream, int ours, int theirs,
int *ours_unmerged, int *theirs_unmerged,
bool *same_changes)
{
int same;

if (stat_branch_pair(branch_name, base, upstream, ours_unmerged,
theirs_unmerged, &same, AHEAD_BEHIND_FULL) < 0)
return false;
if (*ours_unmerged == ours && *theirs_unmerged == theirs)
return false;
*same_changes = *ours_unmerged &&
same == *ours_unmerged + *theirs_unmerged;
return true;
}

bool branch_rebased_cleanly(struct branch *branch, const char *base)
{
const char *upstream = branch_get_upstream(branch, NULL);
int ours, theirs, ours_unmerged, theirs_unmerged;
bool same_changes = false;

if (!upstream || !strcmp(upstream, base))
return false;
if (stat_branch_pair(branch->refname, base, NULL, &ours, &theirs,
NULL, AHEAD_BEHIND_FULL) <= 0 || !ours || !theirs)
return false;
return stat_outside_upstream(branch->refname, base, upstream,
ours, theirs, &ours_unmerged,
&theirs_unmerged, &same_changes) &&
same_changes;
}

static char *resolve_compare_branch(struct branch *branch, const char *name)
@ -2396,6 +2457,9 @@ static void format_branch_comparison(struct strbuf *sb,
const char *branch_name,
const char *push_remote_name,
const char *push_branch_name,
const char *upstream_name,
int ours_unmerged, int theirs_unmerged,
bool same_changes,
enum ahead_behind_flags abf,
unsigned flags)
{
@ -2441,15 +2505,40 @@ static void format_branch_comparison(struct strbuf *sb,
_(" (use \"git pull\" to update your local branch)\n"));
}
} else {
strbuf_addf(sb,
Q_("Your branch and '%s' have diverged,\n"
"and have %d and %d different commit each, "
"respectively.\n",
"Your branch and '%s' have diverged,\n"
"and have %d and %d different commits each, "
"respectively.\n",
ours + theirs),
branch_name, ours, theirs);
if (same_changes) {
strbuf_addf(sb,
Q_("Your branch and '%s' have diverged,\n"
"and have %d and %d different commit each "
"(rebased cleanly on '%s').\n",
"Your branch and '%s' have diverged,\n"
"and have %d and %d different commits each "
"(rebased cleanly on '%s').\n",
ours + theirs),
branch_name, ours, theirs, upstream_name);
if (use_push_advice && advice_enabled(ADVICE_STATUS_HINTS))
strbuf_addstr(sb,
_(" (use \"git push --force-with-lease\" to publish your local commits)\n"));
} else if (upstream_name)
strbuf_addf(sb,
Q_("Your branch and '%s' have diverged,\n"
"and have %d and %d different commit each "
"(%d and %d not in '%s').\n",
"Your branch and '%s' have diverged,\n"
"and have %d and %d different commits each "
"(%d and %d not in '%s').\n",
ours + theirs),
branch_name, ours, theirs,
ours_unmerged, theirs_unmerged, upstream_name);
else
strbuf_addf(sb,
Q_("Your branch and '%s' have diverged,\n"
"and have %d and %d different commit each, "
"respectively.\n",
"Your branch and '%s' have diverged,\n"
"and have %d and %d different commits each, "
"respectively.\n",
ours + theirs),
branch_name, ours, theirs);
if (use_divergence_advice && advice_enabled(ADVICE_STATUS_HINTS)) {
if (push_remote_name && push_branch_name)
strbuf_addf(sb,
@ -2493,7 +2582,10 @@ int format_tracking_info(struct branch *branch, struct strbuf *sb,
for (i = 0; i < branches.nr; i++) {
char *full_ref;
char *short_ref;
char *upstream_name = NULL;
int ours, theirs, cmp;
int ours_unmerged = 0, theirs_unmerged = 0;
bool same_changes = false;
int is_upstream, is_push;
unsigned flags = 0;
const char *push_remote_name = NULL;
@ -2518,8 +2610,17 @@ int format_tracking_info(struct branch *branch, struct strbuf *sb,
if (is_upstream && (!push_ref || !strcmp(upstream_ref, push_ref)))
is_push = 1;

cmp = stat_branch_pair(branch->refname, full_ref,
&ours, &theirs, abf);
cmp = stat_branch_pair(branch->refname, full_ref, NULL,
&ours, &theirs, NULL, abf);

if (cmp > 0 && ours && theirs && upstream_ref && !is_upstream &&
stat_outside_upstream(branch->refname, full_ref,
upstream_ref, ours, theirs,
&ours_unmerged, &theirs_unmerged,
&same_changes))
upstream_name = refs_shorten_unambiguous_ref(
get_main_ref_store(the_repository),
upstream_ref, 0);

if (cmp < 0) {
if (is_upstream) {
@ -2562,11 +2663,14 @@ int format_tracking_info(struct branch *branch, struct strbuf *sb,
}
format_branch_comparison(sb, !cmp, ours, theirs, short_ref,
push_remote_name, push_branch_name,
upstream_name, ours_unmerged,
theirs_unmerged, same_changes,
abf, flags);
reported = 1;

free(full_ref);
free(short_ref);
free(upstream_name);
}

string_list_clear(&branches, 0);

View File

@ -412,6 +412,12 @@ int format_tracking_info(struct branch *branch, struct strbuf *sb,
enum ahead_behind_flags abf,
int show_divergence_advice);

/*
* Return true when the branch has diverged from base only because the
* work on base was rebased cleanly on the upstream of the branch.
*/
bool branch_rebased_cleanly(struct branch *branch, const char *base);

struct ref *get_local_heads(void);

/*

View File

@ -746,4 +746,167 @@ test_expect_success 'status.compareBranches suppresses advice when push tracking
test_cmp expect actual
'

test_expect_success 'status.compareBranches counts push divergence outside upstream' '
test_config -C test push.default current &&
test_config -C test status.compareBranches "@{upstream} @{push}" &&
(
cd test &&
git checkout -b feature18 origin/main &&
advance work18 &&
git push
) &&
git checkout main &&
advance main18a &&
advance main18b &&
git checkout - &&
(
cd test &&
echo amended >work18 &&
git commit -a --amend --no-edit &&
git pull --rebase &&
git status >../actual
) &&
cat >expect <<-EOF &&
On branch feature18
Your branch is ahead of ${SQ}origin/main${SQ} by 1 commit.

Your branch and ${SQ}origin/feature18${SQ} have diverged,
and have 3 and 1 different commits each (1 and 1 not in ${SQ}origin/main${SQ}).

nothing to commit, working tree clean
EOF
test_cmp expect actual
'

test_expect_success 'status.compareBranches after a clean rebase of the push branch' '
test_config -C test push.default current &&
test_config -C test status.compareBranches "@{upstream} @{push}" &&
(
cd test &&
git checkout -b feature19 origin/main &&
advance work19 &&
git push
) &&
git checkout main &&
advance main19a &&
advance main19b &&
git checkout - &&
(
cd test &&
git pull --rebase &&
git status >../actual
) &&
cat >expect <<-EOF &&
On branch feature19
Your branch is ahead of ${SQ}origin/main${SQ} by 1 commit.

Your branch and ${SQ}origin/feature19${SQ} have diverged,
and have 3 and 1 different commits each (rebased cleanly on ${SQ}origin/main${SQ}).
(use "git push --force-with-lease" to publish your local commits)

nothing to commit, working tree clean
EOF
test_cmp expect actual &&
(
cd test &&
test_must_fail git push 2>../push.err &&
git push --force-with-lease origin feature19 &&
git status >../actual
) &&
url=$(git -C test config remote.origin.url) &&
cat >expect <<-EOF &&
To $url
! [rejected] feature19 -> feature19 (non-fast-forward)
error: failed to push some refs to ${SQ}$url${SQ}
hint: Updates were rejected because ${SQ}origin/feature19${SQ} has diverged
hint: from your current branch, which was rebased cleanly on ${SQ}origin/main${SQ}.
hint: Use ${SQ}git push --force-with-lease origin feature19${SQ} to replace it.
EOF
test_cmp expect push.err &&
cat >expect <<-EOF &&
On branch feature19
Your branch is ahead of ${SQ}origin/main${SQ} by 1 commit.

Your branch is up to date with ${SQ}origin/feature19${SQ}.

nothing to commit, working tree clean
EOF
test_cmp expect actual
'

test_expect_success 'push to a push branch someone else updated suggests pulling from it' '
(
cd test &&
git checkout -b feature20 origin/main &&
advance work20 &&
git push origin feature20
) &&
git checkout feature20 &&
advance other20 &&
git checkout - &&
(
cd test &&
advance mine20 &&
git fetch &&
test_must_fail git push origin feature20 2>../actual
) &&
url=$(git -C test config remote.origin.url) &&
cat >expect <<-EOF &&
To $url
! [rejected] feature20 -> feature20 (non-fast-forward)
error: failed to push some refs to ${SQ}$url${SQ}
hint: Updates were rejected because ${SQ}origin/feature20${SQ} has diverged
hint: from your current branch. Use ${SQ}git pull origin feature20${SQ}
hint: to integrate the remote changes.
EOF
test_cmp expect actual
'

test_expect_success 'push to the upstream branch' '
(
cd test &&
git checkout -b feature21 origin/main &&
advance work21 &&
git push -u origin feature21
) &&
git checkout feature21 &&
advance other21 &&
git checkout - &&
(
cd test &&
advance mine21 &&
git fetch &&
test_must_fail git push 2>../actual
) &&
url=$(git -C test config remote.origin.url) &&
cat >expect <<-EOF &&
To $url
! [rejected] feature21 -> feature21 (non-fast-forward)
error: failed to push some refs to ${SQ}$url${SQ}
hint: Updates were rejected because the tip of your current branch is behind
hint: its remote counterpart. If you want to integrate the remote changes,
hint: use ${SQ}git pull${SQ} before pushing again.
hint: See the ${SQ}Note about fast-forwards${SQ} in ${SQ}git push --help${SQ} for details.
EOF
test_cmp expect actual &&
(
cd test &&
git pull --rebase &&
git push &&
echo amended >mine21 &&
git commit -a --amend --no-edit &&
test_must_fail git push 2>../actual
) &&
cat >expect <<-EOF &&
To $url
! [rejected] feature21 -> feature21 (non-fast-forward)
error: failed to push some refs to ${SQ}$url${SQ}
hint: Updates were rejected because ${SQ}origin/feature21${SQ} has diverged
hint: from your current branch. Use ${SQ}git pull origin feature21${SQ}
hint: to integrate the remote changes, or replace them with
hint: ${SQ}git push --force-with-lease origin feature21${SQ}.
EOF
test_cmp expect actual
'

test_done

View File

@ -896,6 +896,42 @@ int transport_summary_width(const struct ref *refs)
return (2 * maxw + 3);
}

struct reflog_has_tip_cb_data {
const struct object_id *target;
int found;
};

static int reflog_has_tip(const char *refname UNUSED,
struct object_id *old_oid UNUSED,
struct object_id *new_oid,
const char *committer UNUSED,
timestamp_t timestamp UNUSED,
int tz UNUSED, const char *msg UNUSED,
void *cb_data)
{
struct reflog_has_tip_cb_data *cb = cb_data;

if (!oideq(new_oid, cb->target))
return 0;
cb->found = 1;
return 1;
}

/*
* Was "refname" ever at "oid" according to its reflog? Then a remote
* sitting at "oid" carries no work we have not seen, only commits we
* have since rewritten, for example with 'commit --amend' or 'rebase'.
*/
static int local_ref_used_to_be_at(const char *refname,
const struct object_id *oid)
{
struct reflog_has_tip_cb_data cb = { .target = oid };

refs_for_each_reflog_ent_reverse(get_main_ref_store(the_repository),
refname, reflog_has_tip, &cb);
return cb.found;
}

void transport_print_push_status(const char *dest, struct ref *refs,
int verbose, int porcelain, unsigned int *reject_reasons)
{
@ -930,9 +966,11 @@ void transport_print_push_status(const char *dest, struct ref *refs,
n += print_one_push_status(ref, dest, n,
porcelain, summary_width);
if (ref->status == REF_STATUS_REJECT_NONFASTFORWARD) {
if (head != NULL && !strcmp(head, ref->name))
if (head != NULL && !strcmp(head, ref->name)) {
*reject_reasons |= REJECT_NON_FF_HEAD;
else
if (local_ref_used_to_be_at(head, &ref->old_oid))
*reject_reasons |= REJECT_NON_FF_HEAD_REWRITE;
} else
*reject_reasons |= REJECT_NON_FF_OTHER;
} else if (ref->status == REF_STATUS_REJECT_ALREADY_EXISTS) {
*reject_reasons |= REJECT_ALREADY_EXISTS;

View File

@ -259,6 +259,7 @@ void transport_set_verbosity(struct transport *transport, int verbosity,
#define REJECT_NEEDS_FORCE 0x10
#define REJECT_REF_NEEDS_UPDATE 0x20
#define REJECT_REF_UNVERIFIABLE 0x40
#define REJECT_NON_FF_HEAD_REWRITE 0x80

int transport_push(struct repository *repo,
struct transport *connection,