diff --git a/builtin/push.c b/builtin/push.c index 2ea73489d1..4cb3e51db7 100644 --- a/builtin/push.c +++ b/builtin/push.c @@ -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=:' 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) { diff --git a/remote.c b/remote.c index 2c2941821e..bfb631083a 100644 --- a/remote.c +++ b/remote.c @@ -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); diff --git a/remote.h b/remote.h index a453c5c2d2..5d213f915f 100644 --- a/remote.h +++ b/remote.h @@ -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); /* diff --git a/t/t6040-tracking-info.sh b/t/t6040-tracking-info.sh index e95d420972..b5339e1452 100755 --- a/t/t6040-tracking-info.sh +++ b/t/t6040-tracking-info.sh @@ -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 diff --git a/transport.c b/transport.c index cc882ed15f..ea68b16bbb 100644 --- a/transport.c +++ b/transport.c @@ -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; diff --git a/transport.h b/transport.h index ba51b65d93..535eae9775 100644 --- a/transport.h +++ b/transport.h @@ -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,