builtin/clone: teach git-clone(1) the --revision= option

The git-clone(1) command has the option `--branch` that allows the user
to select the branch they want HEAD to point to. In a non-bare
repository this also checks out that branch.

Option `--branch` also accepts a tag. When a tag name is provided, the
commit this tag points to is checked out and HEAD is detached. Thus
`--branch` can be used to clone a repository and check out a ref kept
under `refs/heads` or `refs/tags`. But some other refs might be in use
as well. For example Git forges might use refs like `refs/pull/<id>` and
`refs/merge-requests/<id>` to track pull/merge requests. These refs
cannot be selected upon git-clone(1).

Add option `--revision` to git-clone(1). This option accepts a fully
qualified reference, or a hexadecimal commit ID. This enables the user
to clone and check out any revision they want. `--revision` can be used
in conjunction with `--depth` to do a minimal clone that only contains
the blob and tree for a single revision. This can be useful for
automated tests running in CI systems.

Using option `--branch` and `--single-branch` together is a similar
scenario, but serves a different purpose. Using these two options, a
singlet remote tracking branch is created and the fetch refspec is set
up so git-fetch(1) will receive updates on that branch from the remote.
This allows the user work on that single branch.

Option `--revision` on contrary detaches HEAD, creates no tracking
branches, and writes no fetch refspec.

Signed-off-by: Toon Claes <toon@iotcl.com>
Acked-by: Patrick Steinhardt <ps@pks.im>
[jc: removed unnecessary TEST_PASSES_SANITIZE_LEAK from the test]
Signed-off-by: Junio C Hamano <gitster@pobox.com>
maint
Toon Claes 2025-02-06 07:33:35 +01:00 committed by Junio C Hamano
parent 9144b9362b
commit 337855629f
4 changed files with 178 additions and 11 deletions

View File

@ -221,6 +221,15 @@ objects from the source repository into a pack in the cloned repository.
`--branch` can also take tags and detaches the `HEAD` at that commit `--branch` can also take tags and detaches the `HEAD` at that commit
in the resulting repository. in the resulting repository.


`--revision=<rev>`::
Create a new repository, and fetch the history leading to the given
revision _<rev>_ (and nothing else), without making any remote-tracking
branch, and without making any local branch, and detach `HEAD` to
_<rev>_. The argument can be a ref name (e.g. `refs/heads/main` or
`refs/tags/v1.0`) that peels down to a commit, or a hexadecimal object
name.
This option is incompatible with `--branch` and `--mirror`.

`-u` _<upload-pack>_:: `-u` _<upload-pack>_::
`--upload-pack` _<upload-pack>_:: `--upload-pack` _<upload-pack>_::
When given, and the repository to clone from is accessed When given, and the repository to clone from is accessed

View File

@ -59,6 +59,7 @@


struct clone_opts { struct clone_opts {
int wants_head; int wants_head;
int detach;
}; };
#define CLONE_OPTS_INIT { \ #define CLONE_OPTS_INIT { \
.wants_head = 1 /* default enabled */ \ .wants_head = 1 /* default enabled */ \
@ -565,11 +566,11 @@ static void update_remote_refs(const struct ref *refs,
} }
} }


static void update_head(const struct ref *our, const struct ref *remote, static void update_head(struct clone_opts *opts, const struct ref *our, const struct ref *remote,
const char *unborn, const char *msg) const char *unborn, const char *msg)
{ {
const char *head; const char *head;
if (our && skip_prefix(our->name, "refs/heads/", &head)) { if (our && !opts->detach && skip_prefix(our->name, "refs/heads/", &head)) {
/* Local default branch link */ /* Local default branch link */
if (refs_update_symref(get_main_ref_store(the_repository), "HEAD", our->name, NULL) < 0) if (refs_update_symref(get_main_ref_store(the_repository), "HEAD", our->name, NULL) < 0)
die(_("unable to update HEAD")); die(_("unable to update HEAD"));
@ -580,8 +581,9 @@ static void update_head(const struct ref *our, const struct ref *remote,
install_branch_config(0, head, remote_name, our->name); install_branch_config(0, head, remote_name, our->name);
} }
} else if (our) { } else if (our) {
struct commit *c = lookup_commit_reference(the_repository, struct commit *c = lookup_commit_or_die(&our->old_oid,
&our->old_oid); our->name);

/* --branch specifies a non-branch (i.e. tags), detach HEAD */ /* --branch specifies a non-branch (i.e. tags), detach HEAD */
refs_update_ref(get_main_ref_store(the_repository), msg, refs_update_ref(get_main_ref_store(the_repository), msg,
"HEAD", &c->object.oid, NULL, REF_NO_DEREF, "HEAD", &c->object.oid, NULL, REF_NO_DEREF,
@ -900,6 +902,7 @@ int cmd_clone(int argc,
int option_filter_submodules = -1; /* unspecified */ int option_filter_submodules = -1; /* unspecified */
struct string_list server_options = STRING_LIST_INIT_NODUP; struct string_list server_options = STRING_LIST_INIT_NODUP;
const char *bundle_uri = NULL; const char *bundle_uri = NULL;
char *option_rev = NULL;


struct clone_opts opts = CLONE_OPTS_INIT; struct clone_opts opts = CLONE_OPTS_INIT;


@ -943,6 +946,8 @@ int cmd_clone(int argc,
N_("use <name> instead of 'origin' to track upstream")), N_("use <name> instead of 'origin' to track upstream")),
OPT_STRING('b', "branch", &option_branch, N_("branch"), OPT_STRING('b', "branch", &option_branch, N_("branch"),
N_("checkout <branch> instead of the remote's HEAD")), N_("checkout <branch> instead of the remote's HEAD")),
OPT_STRING(0, "revision", &option_rev, N_("rev"),
N_("clone single revision <rev> and check out")),
OPT_STRING('u', "upload-pack", &option_upload_pack, N_("path"), OPT_STRING('u', "upload-pack", &option_upload_pack, N_("path"),
N_("path to git-upload-pack on the remote")), N_("path to git-upload-pack on the remote")),
OPT_STRING(0, "depth", &option_depth, N_("depth"), OPT_STRING(0, "depth", &option_depth, N_("depth"),
@ -1279,7 +1284,7 @@ int cmd_clone(int argc,
strbuf_addstr(&branch_top, src_ref_prefix); strbuf_addstr(&branch_top, src_ref_prefix);


git_config_set("core.bare", "true"); git_config_set("core.bare", "true");
} else { } else if (!option_rev) {
strbuf_addf(&branch_top, "refs/remotes/%s/", remote_name); strbuf_addf(&branch_top, "refs/remotes/%s/", remote_name);
} }


@ -1298,6 +1303,7 @@ int cmd_clone(int argc,


remote = remote_get_early(remote_name); remote = remote_get_early(remote_name);


if (!option_rev)
refspec_appendf(&remote->fetch, "+%s*:%s*", src_ref_prefix, refspec_appendf(&remote->fetch, "+%s*:%s*", src_ref_prefix,
branch_top.buf); branch_top.buf);


@ -1342,6 +1348,11 @@ int cmd_clone(int argc,


transport_set_option(transport, TRANS_OPT_KEEP, "yes"); transport_set_option(transport, TRANS_OPT_KEEP, "yes");


die_for_incompatible_opt2(!!option_rev, "--revision",
!!option_branch, "--branch");
die_for_incompatible_opt2(!!option_rev, "--revision",
option_mirror, "--mirror");

if (reject_shallow) if (reject_shallow)
transport_set_option(transport, TRANS_OPT_REJECT_SHALLOW, "1"); transport_set_option(transport, TRANS_OPT_REJECT_SHALLOW, "1");
if (option_depth) if (option_depth)
@ -1378,7 +1389,14 @@ int cmd_clone(int argc,
if (transport->smart_options && !deepen && !filter_options.choice) if (transport->smart_options && !deepen && !filter_options.choice)
transport->smart_options->check_self_contained_and_connected = 1; transport->smart_options->check_self_contained_and_connected = 1;


strvec_push(&transport_ls_refs_options.ref_prefixes, "HEAD"); if (option_rev) {
option_tags = 0;
option_single_branch = 0;
opts.wants_head = 0;
opts.detach = 1;

refspec_append(&remote->fetch, option_rev);
}


if (option_tags || option_branch) if (option_tags || option_branch)
/* /*
@ -1393,6 +1411,17 @@ int cmd_clone(int argc,
expand_ref_prefix(&transport_ls_refs_options.ref_prefixes, expand_ref_prefix(&transport_ls_refs_options.ref_prefixes,
option_branch); option_branch);


/*
* As part of transport_get_remote_refs() the server tells us the hash
* algorithm, which we require to initialize the repo. But calling that
* function without any ref prefix, will cause the server to announce
* all known refs. If the argument passed to --revision was a hex oid,
* ref_prefixes will be empty so we fall back to asking about HEAD to
* reduce traffic from the server.
*/
if (opts.wants_head || transport_ls_refs_options.ref_prefixes.nr == 0)
strvec_push(&transport_ls_refs_options.ref_prefixes, "HEAD");

refs = transport_get_remote_refs(transport, &transport_ls_refs_options); refs = transport_get_remote_refs(transport, &transport_ls_refs_options);


/* /*
@ -1501,6 +1530,11 @@ int cmd_clone(int argc,
if (!our_head_points_at) if (!our_head_points_at)
die(_("Remote branch %s not found in upstream %s"), die(_("Remote branch %s not found in upstream %s"),
option_branch, remote_name); option_branch, remote_name);
} else if (option_rev) {
our_head_points_at = mapped_refs;
if (!our_head_points_at)
die(_("Remote revision %s not found in upstream %s"),
option_rev, remote_name);
} else if (remote_head_points_at) { } else if (remote_head_points_at) {
our_head_points_at = remote_head_points_at; our_head_points_at = remote_head_points_at;
} else if (remote_head) { } else if (remote_head) {
@ -1539,6 +1573,7 @@ int cmd_clone(int argc,
free(to_free); free(to_free);
} }


if (!option_rev)
write_refspec_config(src_ref_prefix, our_head_points_at, write_refspec_config(src_ref_prefix, our_head_points_at,
remote_head_points_at, &branch_top); remote_head_points_at, &branch_top);


@ -1556,7 +1591,7 @@ int cmd_clone(int argc,
branch_top.buf, reflog_msg.buf, transport, branch_top.buf, reflog_msg.buf, transport,
!is_local); !is_local);


update_head(our_head_points_at, remote_head, unborn_head, reflog_msg.buf); update_head(&opts, our_head_points_at, remote_head, unborn_head, reflog_msg.buf);


/* /*
* We want to show progress for recursive submodule clones iff * We want to show progress for recursive submodule clones iff

View File

@ -721,6 +721,7 @@ integration_tests = [
't5617-clone-submodules-remote.sh', 't5617-clone-submodules-remote.sh',
't5618-alternate-refs.sh', 't5618-alternate-refs.sh',
't5619-clone-local-ambiguous-transport.sh', 't5619-clone-local-ambiguous-transport.sh',
't5621-clone-revision.sh',
't5700-protocol-v1.sh', 't5700-protocol-v1.sh',
't5701-git-serve.sh', 't5701-git-serve.sh',
't5702-protocol-v2.sh', 't5702-protocol-v2.sh',

122
t/t5621-clone-revision.sh Executable file
View File

@ -0,0 +1,122 @@
#!/bin/sh

test_description='tests for git clone --revision'
GIT_TEST_DEFAULT_INITIAL_BRANCH_NAME=main
export GIT_TEST_DEFAULT_INITIAL_BRANCH_NAME

. ./test-lib.sh

test_expect_success 'setup' '
test_commit --no-tag "initial commit" README "Hello" &&
test_commit --annotate "second commit" README "Hello world" v1.0 &&
test_commit --no-tag "third commit" README "Hello world!" &&
git switch -c feature v1.0 &&
test_commit --no-tag "feature commit" README "Hello world!" &&
git switch main
'

test_expect_success 'clone with --revision being a branch' '
test_when_finished "rm -rf dst" &&
git clone --revision=refs/heads/feature . dst &&
git rev-parse refs/heads/feature >expect &&
git -C dst rev-parse HEAD >actual &&
test_must_fail git -C dst symbolic-ref -q HEAD >/dev/null &&
test_cmp expect actual &&
git -C dst for-each-ref refs >expect &&
test_must_be_empty expect &&
test_must_fail git -C dst config remote.origin.fetch
'

test_expect_success 'clone with --depth and --revision being a branch' '
test_when_finished "rm -rf dst" &&
git clone --no-local --depth=1 --revision=refs/heads/feature . dst &&
git rev-parse refs/heads/feature >expect &&
git -C dst rev-parse HEAD >actual &&
test_must_fail git -C dst symbolic-ref -q HEAD >/dev/null &&
test_cmp expect actual &&
git -C dst for-each-ref refs >expect &&
test_must_be_empty expect &&
test_must_fail git -C dst config remote.origin.fetch &&
git -C dst rev-list HEAD >actual &&
test_line_count = 1 actual
'

test_expect_success 'clone with --revision being a tag' '
test_when_finished "rm -rf dst" &&
git clone --revision=refs/tags/v1.0 . dst &&
git rev-parse refs/tags/v1.0^{} >expect &&
git -C dst rev-parse HEAD >actual &&
test_must_fail git -C dst symbolic-ref -q HEAD >/dev/null &&
test_cmp expect actual &&
git -C dst for-each-ref refs >expect &&
test_must_be_empty expect &&
test_must_fail git -C dst config remote.origin.fetch
'

test_expect_success 'clone with --revision being HEAD' '
test_when_finished "rm -rf dst" &&
git clone --revision=HEAD . dst &&
git rev-parse HEAD >expect &&
git -C dst rev-parse HEAD >actual &&
test_must_fail git -C dst symbolic-ref -q HEAD >/dev/null &&
test_cmp expect actual &&
git -C dst for-each-ref refs >expect &&
test_must_be_empty expect &&
test_must_fail git -C dst config remote.origin.fetch
'

test_expect_success 'clone with --revision being a raw commit hash' '
test_when_finished "rm -rf dst" &&
oid=$(git rev-parse refs/heads/feature) &&
git clone --revision=$oid . dst &&
echo $oid >expect &&
git -C dst rev-parse HEAD >actual &&
test_must_fail git -C dst symbolic-ref -q HEAD >/dev/null &&
test_cmp expect actual &&
git -C dst for-each-ref refs >expect &&
test_must_be_empty expect &&
test_must_fail git -C dst config remote.origin.fetch
'

test_expect_success 'clone with --revision and --bare' '
test_when_finished "rm -rf dst" &&
git clone --revision=refs/heads/main --bare . dst &&
oid=$(git rev-parse refs/heads/main) &&
git -C dst cat-file -t $oid >actual &&
echo "commit" >expect &&
test_cmp expect actual &&
git -C dst for-each-ref refs >expect &&
test_must_be_empty expect &&
test_must_fail git -C dst config remote.origin.fetch
'

test_expect_success 'clone with --revision being a short raw commit hash' '
test_when_finished "rm -rf dst" &&
oid=$(git rev-parse --short refs/heads/feature) &&
test_must_fail git clone --revision=$oid . dst 2>err &&
test_grep "fatal: Remote revision $oid not found in upstream origin" err
'

test_expect_success 'clone with --revision being a tree hash' '
test_when_finished "rm -rf dst" &&
oid=$(git rev-parse refs/heads/feature^{tree}) &&
test_must_fail git clone --revision=$oid . dst 2>err &&
test_grep "error: object $oid is a tree, not a commit" err
'

test_expect_success 'clone with --revision being the parent of a ref fails' '
test_when_finished "rm -rf dst" &&
test_must_fail git clone --revision=refs/heads/main^ . dst
'

test_expect_success 'clone with --revision and --branch fails' '
test_when_finished "rm -rf dst" &&
test_must_fail git clone --revision=refs/heads/main --branch=main . dst
'

test_expect_success 'clone with --revision and --mirror fails' '
test_when_finished "rm -rf dst" &&
test_must_fail git clone --revision=refs/heads/main --mirror . dst
'

test_done