Merge branch 'hn/shallow-history-advice' into seen

Providing advice when ~<n> or ^<n> resolves to a commit whose
parents are missing due to the repository being a shallow clone,
instead of an unhelpful 'not a commit' error.

* hn/shallow-history-advice:
  object-name: explain why <rev>~N fails in a shallow clone
seen
Junio C Hamano 2026-10-12 09:26:13 -07:00
commit bf970699c6
7 changed files with 266 additions and 7 deletions

View File

@ -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 `<rev>~<n>` cannot resolve enough ancestors of
`<rev>` 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.

View File

@ -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" },

View File

@ -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,

View File

@ -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/<remote>/<branch>" 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~<n>" or "name^<n>" 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^<n>"
* 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 <n> 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 <remote> <branch>",
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);

View File

@ -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;

View File

@ -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.
*

View File

@ -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 &&