diff --git a/Documentation/config/advice.adoc b/Documentation/config/advice.adoc index 900f0e5da9..83039ba48b 100644 --- a/Documentation/config/advice.adoc +++ b/Documentation/config/advice.adoc @@ -136,6 +136,10 @@ all advice messages. give directions on how to proceed from the current state. sequencerInUse:: Shown when a sequencer command is already in progress. + shallowHistory:: + Shown when `~` cannot resolve enough ancestors of + `` because history stops at a shallow boundary, to + suggest fetching more history. skippedCherryPicks:: Shown when linkgit:git-rebase[1] skips a commit that has already been cherry-picked onto the upstream branch. diff --git a/advice.c b/advice.c index 7e82dd9782..7253891f92 100644 --- a/advice.c +++ b/advice.c @@ -82,6 +82,7 @@ static struct { [ADVICE_RM_HINTS] = { "rmHints" }, [ADVICE_SEQUENCER_IN_USE] = { "sequencerInUse" }, [ADVICE_SET_UPSTREAM_FAILURE] = { "setUpstreamFailure" }, + [ADVICE_SHALLOW_HISTORY] = { "shallowHistory" }, [ADVICE_SKIPPED_CHERRY_PICKS] = { "skippedCherryPicks" }, [ADVICE_SPARSE_INDEX_EXPANDED] = { "sparseIndexExpanded" }, [ADVICE_STATUS_AHEAD_BEHIND_WARNING] = { "statusAheadBehindWarning" }, diff --git a/advice.h b/advice.h index 1a2f293b57..a443bc7aeb 100644 --- a/advice.h +++ b/advice.h @@ -49,6 +49,7 @@ enum advice_type { ADVICE_RM_HINTS, ADVICE_SEQUENCER_IN_USE, ADVICE_SET_UPSTREAM_FAILURE, + ADVICE_SHALLOW_HISTORY, ADVICE_SKIPPED_CHERRY_PICKS, ADVICE_SPARSE_INDEX_EXPANDED, ADVICE_STATUS_AHEAD_BEHIND_WARNING, diff --git a/object-name.c b/object-name.c index 19fc4fdead..0217f99964 100644 --- a/object-name.c +++ b/object-name.c @@ -22,6 +22,7 @@ #include "repo-settings.h" #include "repository.h" #include "setup.h" +#include "shallow.h" #include "midx.h" #include "commit-reach.h" #include "date.h" @@ -817,13 +818,120 @@ static int get_oid_basic(struct repository *r, const char *str, int len, return 0; } +struct remote_tracking_search { + char *dst; + char *remote_name; + char *branch_name; +}; + +static int search_remote_tracking(struct remote *remote, void *cb_data) +{ + struct remote_tracking_search *search = cb_data; + struct refspec_item query; + + memset(&query, 0, sizeof(query)); + query.dst = search->dst; + if (remote_find_tracking(remote, &query)) + return 0; + search->remote_name = xstrdup(remote->name); + search->branch_name = query.src; + return 1; +} + +/* + * If "name" resolves to a remote-tracking ref, find which configured + * remote it actually belongs to and what branch of that remote's it is, + * by reverse mapping through each remote's real fetch refspec instead + * of assuming the common "refs/remotes//" layout, + * since a remote's refspec need not put its branches there at all. On a + * match, fill "remote_out" and "branch_out" with newly allocated copies + * and return true. Otherwise leave them untouched and return false. + */ +static bool parse_remote_tracking_name(struct repository *r, + const char *name, int namelen, + char **remote_out, char **branch_out) +{ + struct object_id oid; + char *real_ref = NULL; + struct remote_tracking_search search = { 0 }; + const char *branch_name; + + if (!repo_dwim_ref(r, name, namelen, &oid, &real_ref, 0) || !real_ref) + return false; + + search.dst = real_ref; + if (!for_each_remote(search_remote_tracking, &search)) { + free(real_ref); + return false; + } + free(real_ref); + + branch_name = search.branch_name; + skip_prefix(branch_name, "refs/heads/", &branch_name); + *remote_out = search.remote_name; + *branch_out = xstrdup(branch_name); + free(search.branch_name); + return true; +} + +/* + * When a "name~" or "name^" walk runs out of parents at "commit", + * and that is because "commit" is where this shallow repository's history + * was cut off (rather than commit being a real root commit), let the + * user know that fetching more history might be what they are after. + * + * "suggested_depth" is the --deepen value to recommend. For "name^" + * this is always 1: deepening by one generation fetches "commit"'s real + * parent list in full, whatever it turns out to contain, regardless of + * which parent index asked for. + */ +static void advise_if_shallow_cutoff(struct repository *r, + const char *name, int namelen, + struct commit *commit, + unsigned lookup_flags, + int suggested_depth) +{ + char *remote = NULL, *branch = NULL; + struct strbuf cmd = STRBUF_INIT; + + if (lookup_flags & GET_OID_QUIETLY) + return; + if (!is_repository_shallow(r)) + return; + if (!commit_is_shallow_boundary(r, &commit->object.oid)) + return; + + if (parse_remote_tracking_name(r, name, namelen, &remote, &branch)) + strbuf_addf(&cmd, "git fetch --deepen=%d %s %s", + suggested_depth, remote, branch); + else + strbuf_addf(&cmd, "git fetch --deepen=%d ", + suggested_depth); + free(remote); + free(branch); + + advise_if_enabled(ADVICE_SHALLOW_HISTORY, + _("'%.*s' does not have that many ancestors locally.\n" + "History stops at %s because this repository is a\n" + "shallow clone, and might have more history upstream.\n" + "To check, try:\n" + "\n" + " %s"), + namelen, name, + repo_find_unique_abbrev(r, &commit->object.oid, DEFAULT_ABBREV), + cmd.buf); + strbuf_release(&cmd); +} + static enum get_oid_result get_parent(struct repository *r, const char *name, int len, - struct object_id *result, int idx) + struct object_id *result, int idx, + unsigned lookup_flags) { struct object_id oid; enum get_oid_result ret = get_oid_1(r, name, len, &oid, - GET_OID_COMMITTISH); + GET_OID_COMMITTISH | + (lookup_flags & GET_OID_QUIETLY)); struct commit *commit; struct commit_list *p; @@ -844,19 +952,22 @@ static enum get_oid_result get_parent(struct repository *r, } p = p->next; } + advise_if_shallow_cutoff(r, name, len, commit, lookup_flags, 1); return MISSING_OBJECT; } static enum get_oid_result get_nth_ancestor(struct repository *r, const char *name, int len, struct object_id *result, - int generation) + int generation, + unsigned lookup_flags) { struct object_id oid; struct commit *commit; int ret; - ret = get_oid_1(r, name, len, &oid, GET_OID_COMMITTISH); + ret = get_oid_1(r, name, len, &oid, + GET_OID_COMMITTISH | (lookup_flags & GET_OID_QUIETLY)); if (ret) return ret; commit = lookup_commit_reference(r, &oid); @@ -864,8 +975,14 @@ static enum get_oid_result get_nth_ancestor(struct repository *r, return MISSING_OBJECT; while (generation--) { - if (repo_parse_commit(r, commit) || !commit->parents) + if (repo_parse_commit(r, commit)) return MISSING_OBJECT; + if (!commit->parents) { + /* Remaining "generation" plus this failed step is the actual gap. */ + advise_if_shallow_cutoff(r, name, len, commit, + lookup_flags, generation + 1); + return MISSING_OBJECT; + } commit = commit->parents->item; } oidcpy(result, &commit->object.oid); @@ -1112,9 +1229,9 @@ static enum get_oid_result get_oid_1(struct repository *r, else if (num > INT_MAX) return MISSING_OBJECT; if (has_suffix == '^') - return get_parent(r, name, len1, oid, num); + return get_parent(r, name, len1, oid, num, lookup_flags); /* else if (has_suffix == '~') -- goes without saying */ - return get_nth_ancestor(r, name, len1, oid, num); + return get_nth_ancestor(r, name, len1, oid, num, lookup_flags); } ret = peel_onion(r, name, len, oid, lookup_flags); diff --git a/shallow.c b/shallow.c index 8e244a5669..2e0cf09f32 100644 --- a/shallow.c +++ b/shallow.c @@ -92,6 +92,14 @@ int is_repository_shallow(struct repository *r) return r->parsed_objects->is_shallow; } +int commit_is_shallow_boundary(struct repository *r, + const struct object_id *oid) +{ + struct commit_graft *graft = lookup_commit_graft(r, oid); + + return graft && graft->nr_parent == -1; +} + static void reset_repository_shallow(struct repository *r) { r->parsed_objects->is_shallow = -1; diff --git a/shallow.h b/shallow.h index 6a64db42c9..66c9e723f7 100644 --- a/shallow.h +++ b/shallow.h @@ -15,6 +15,14 @@ int register_shallow(struct repository *r, const struct object_id *oid); int unregister_shallow(const struct object_id *oid); int is_repository_shallow(struct repository *r); +/* + * True if "oid" is where this shallow repository's history was cut off, + * i.e. a shallow-graft commit with no parents recorded locally, as + * opposed to a commit that is actually a root. + */ +int commit_is_shallow_boundary(struct repository *r, + const struct object_id *oid); + /* * Lock for updating the $GIT_DIR/shallow file. * diff --git a/t/t1500-rev-parse.sh b/t/t1500-rev-parse.sh index 30bf8a4d0a..813162051b 100755 --- a/t/t1500-rev-parse.sh +++ b/t/t1500-rev-parse.sh @@ -193,6 +193,126 @@ test_expect_success 'rev-parse --is-shallow-repository in non-shallow repo' ' test_cmp expect actual ' +check_shallow_history_advice () { + name=$1 oid=$2 cmd=$3 && + grep '^hint:' err >actual && + cat >expect <<-EOF && + hint: '$name' does not have that many ancestors locally. + hint: History stops at $oid because this repository is a + hint: shallow clone, and might have more history upstream. + hint: To check, try: + hint: + hint: $cmd + hint: Disable this message with "git config set --global advice.shallowHistory false" + EOF + test_cmp expect actual +} + +test_expect_success 'shallowHistory advice on ~N beyond shallow boundary' ' + test_commit shallow_advice_1 && + test_commit shallow_advice_2 && + git clone --no-local --depth=1 --branch main --single-branch \ + .git shallow-advice && + test_when_finished "rm -rf shallow-advice" && + oid=$(git -C shallow-advice rev-parse --short origin/main) && + test_must_fail git -C shallow-advice rev-parse origin/main~1 2>err && + check_shallow_history_advice origin/main "$oid" \ + "git fetch --deepen=1 origin main" +' + +test_expect_success 'shallowHistory advice accounts for depth already present' ' + test_commit shallow_partial_1 && + test_commit shallow_partial_2 && + test_commit shallow_partial_3 && + test_commit shallow_partial_4 && + test_commit shallow_partial_5 && + test_commit shallow_partial_6 && + git clone --no-local --depth=3 --branch main --single-branch \ + .git shallow-advice-partial && + test_when_finished "rm -rf shallow-advice-partial" && + ( + cd shallow-advice-partial && + oid=$(git rev-parse --short origin/main~2) && + test_must_fail git rev-parse origin/main~5 2>err && + check_shallow_history_advice origin/main "$oid" \ + "git fetch --deepen=3 origin main" && + git fetch --deepen=3 origin && + git rev-parse origin/main~5 && + test_must_fail git rev-parse origin/main~6 + ) +' + +test_expect_success 'shallowHistory advice on ^N (first parent) beyond shallow boundary' ' + test_commit shallow_caret_1 && + test_commit shallow_caret_2 && + git clone --no-local --depth=1 --branch main --single-branch \ + .git shallow-advice-caret && + test_when_finished "rm -rf shallow-advice-caret" && + oid=$(git -C shallow-advice-caret rev-parse --short origin/main) && + test_must_fail git -C shallow-advice-caret rev-parse origin/main^1 2>err && + check_shallow_history_advice origin/main "$oid" \ + "git fetch --deepen=1 origin main" +' + +test_expect_success 'shallowHistory advice on chained ^ shows the hint only once' ' + test_commit shallow_chain_1 && + git clone --no-local --depth=1 --branch main --single-branch \ + .git shallow-advice-chain && + test_when_finished "rm -rf shallow-advice-chain" && + oid=$(git -C shallow-advice-chain rev-parse --short origin/main) && + test_must_fail git -C shallow-advice-chain rev-parse origin/main^^ 2>err && + check_shallow_history_advice origin/main "$oid" \ + "git fetch --deepen=1 origin main" +' + +test_expect_success 'shallowHistory advice on ^N suggests deepen=1 even for a merge parent' ' + test_commit shallow_merge_base && + git checkout -q -b shallow-merge-side && + test_commit shallow_merge_side1 && + git checkout -q main && + test_commit shallow_merge_main1 && + git merge -q --no-ff shallow-merge-side -m "shallow merge commit" && + git clone --no-local --depth=1 --branch main --single-branch \ + .git shallow-advice-merge && + test_when_finished "rm -rf shallow-advice-merge" && + ( + cd shallow-advice-merge && + oid=$(git rev-parse --short origin/main) && + test_must_fail git rev-parse origin/main^2 2>err && + check_shallow_history_advice origin/main "$oid" \ + "git fetch --deepen=1 origin main" && + git fetch -q --deepen=1 origin && + git rev-parse origin/main^1 && + git rev-parse origin/main^2 + ) +' + +test_expect_success 'shallowHistory advice can be disabled' ' + test_commit shallow_off_1 && + git clone --no-local --depth=1 --branch main --single-branch \ + .git shallow-advice-off && + test_when_finished "rm -rf shallow-advice-off" && + test_must_fail git -C shallow-advice-off \ + -c advice.shallowHistory=false rev-parse origin/main~1 2>err && + test_grep ! "^hint:" err +' + +test_expect_success 'shallowHistory advice not shown for a non-shallow repository' ' + test_must_fail git rev-parse HEAD~100000 2>err && + test_grep ! "^hint:" err +' + +test_expect_success 'shallowHistory advice not shown when resolution succeeds' ' + test_commit shallow_ok_1 && + test_commit shallow_ok_2 && + test_commit shallow_ok_3 && + git clone --no-local --depth=3 --branch main --single-branch \ + .git shallow-advice-ok && + test_when_finished "rm -rf shallow-advice-ok" && + git -C shallow-advice-ok rev-parse origin/main~1 >actual 2>err && + test_grep ! "^hint:" err +' + test_expect_success 'rev-parse --show-object-format in repo' ' test_oid algo >expect && git rev-parse --show-object-format >actual &&