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>

Toon Claes committed Feb 6, 2025 at 07:33 UTC 337855629f59a3f435dabef900e22202ce8e00e1
4 files changed +178 -11
Documentation/git-clone.txt
+9
@@ -221,6 +221,15 @@ objects from the source repository into a pack in the cloned repository.
221 `--branch` can also take tags and detaches the `HEAD` at that commit
222 in the resulting repository.
223
224 +`--revision=<rev>`::
225 + Create a new repository, and fetch the history leading to the given
226 + revision _<rev>_ (and nothing else), without making any remote-tracking
227 + branch, and without making any local branch, and detach `HEAD` to
228 + _<rev>_. The argument can be a ref name (e.g. `refs/heads/main` or
229 + `refs/tags/v1.0`) that peels down to a commit, or a hexadecimal object
230 + name.
231 + This option is incompatible with `--branch` and `--mirror`.
232 +
233 `-u` _<upload-pack>_::
234 `--upload-pack` _<upload-pack>_::
235 When given, and the repository to clone from is accessed
builtin/clone.c
+46 -11
@@ -59,6 +59,7 @@
59
60 struct clone_opts {
61 int wants_head;
62 + int detach;
63 };
64 #define CLONE_OPTS_INIT { \
65 .wants_head = 1 /* default enabled */ \
@@ -565,11 +566,11 @@ static void update_remote_refs(const struct ref *refs,
566 }
567 }
568
568 -static void update_head(const struct ref *our, const struct ref *remote,
569 +static void update_head(struct clone_opts *opts, const struct ref *our, const struct ref *remote,
570 const char *unborn, const char *msg)
571 {
572 const char *head;
572 - if (our && skip_prefix(our->name, "refs/heads/", &head)) {
573 + if (our && !opts->detach && skip_prefix(our->name, "refs/heads/", &head)) {
574 /* Local default branch link */
575 if (refs_update_symref(get_main_ref_store(the_repository), "HEAD", our->name, NULL) < 0)
576 die(_("unable to update HEAD"));
@@ -580,8 +581,9 @@ static void update_head(const struct ref *our, const struct ref *remote,
581 install_branch_config(0, head, remote_name, our->name);
582 }
583 } else if (our) {
583 - struct commit *c = lookup_commit_reference(the_repository,
584 - &our->old_oid);
584 + struct commit *c = lookup_commit_or_die(&our->old_oid,
585 + our->name);
586 +
587 /* --branch specifies a non-branch (i.e. tags), detach HEAD */
588 refs_update_ref(get_main_ref_store(the_repository), msg,
589 "HEAD", &c->object.oid, NULL, REF_NO_DEREF,
@@ -900,6 +902,7 @@ int cmd_clone(int argc,
902 int option_filter_submodules = -1; /* unspecified */
903 struct string_list server_options = STRING_LIST_INIT_NODUP;
904 const char *bundle_uri = NULL;
905 + char *option_rev = NULL;
906
907 struct clone_opts opts = CLONE_OPTS_INIT;
908
@@ -943,6 +946,8 @@ int cmd_clone(int argc,
946 N_("use <name> instead of 'origin' to track upstream")),
947 OPT_STRING('b', "branch", &option_branch, N_("branch"),
948 N_("checkout <branch> instead of the remote's HEAD")),
949 + OPT_STRING(0, "revision", &option_rev, N_("rev"),
950 + N_("clone single revision <rev> and check out")),
951 OPT_STRING('u', "upload-pack", &option_upload_pack, N_("path"),
952 N_("path to git-upload-pack on the remote")),
953 OPT_STRING(0, "depth", &option_depth, N_("depth"),
@@ -1279,7 +1284,7 @@ int cmd_clone(int argc,
1284 strbuf_addstr(&branch_top, src_ref_prefix);
1285
1286 git_config_set("core.bare", "true");
1282 - } else {
1287 + } else if (!option_rev) {
1288 strbuf_addf(&branch_top, "refs/remotes/%s/", remote_name);
1289 }
1290
@@ -1298,8 +1303,9 @@ int cmd_clone(int argc,
1303
1304 remote = remote_get_early(remote_name);
1305
1301 - refspec_appendf(&remote->fetch, "+%s*:%s*", src_ref_prefix,
1302 - branch_top.buf);
1306 + if (!option_rev)
1307 + refspec_appendf(&remote->fetch, "+%s*:%s*", src_ref_prefix,
1308 + branch_top.buf);
1309
1310 path = get_repo_path(remote->url.v[0], &is_bundle);
1311 is_local = option_local != 0 && path && !is_bundle;
@@ -1342,6 +1348,11 @@ int cmd_clone(int argc,
1348
1349 transport_set_option(transport, TRANS_OPT_KEEP, "yes");
1350
1351 + die_for_incompatible_opt2(!!option_rev, "--revision",
1352 + !!option_branch, "--branch");
1353 + die_for_incompatible_opt2(!!option_rev, "--revision",
1354 + option_mirror, "--mirror");
1355 +
1356 if (reject_shallow)
1357 transport_set_option(transport, TRANS_OPT_REJECT_SHALLOW, "1");
1358 if (option_depth)
@@ -1378,7 +1389,14 @@ int cmd_clone(int argc,
1389 if (transport->smart_options && !deepen && !filter_options.choice)
1390 transport->smart_options->check_self_contained_and_connected = 1;
1391
1381 - strvec_push(&transport_ls_refs_options.ref_prefixes, "HEAD");
1392 + if (option_rev) {
1393 + option_tags = 0;
1394 + option_single_branch = 0;
1395 + opts.wants_head = 0;
1396 + opts.detach = 1;
1397 +
1398 + refspec_append(&remote->fetch, option_rev);
1399 + }
1400
1401 if (option_tags || option_branch)
1402 /*
@@ -1393,6 +1411,17 @@ int cmd_clone(int argc,
1411 expand_ref_prefix(&transport_ls_refs_options.ref_prefixes,
1412 option_branch);
1413
1414 + /*
1415 + * As part of transport_get_remote_refs() the server tells us the hash
1416 + * algorithm, which we require to initialize the repo. But calling that
1417 + * function without any ref prefix, will cause the server to announce
1418 + * all known refs. If the argument passed to --revision was a hex oid,
1419 + * ref_prefixes will be empty so we fall back to asking about HEAD to
1420 + * reduce traffic from the server.
1421 + */
1422 + if (opts.wants_head || transport_ls_refs_options.ref_prefixes.nr == 0)
1423 + strvec_push(&transport_ls_refs_options.ref_prefixes, "HEAD");
1424 +
1425 refs = transport_get_remote_refs(transport, &transport_ls_refs_options);
1426
1427 /*
@@ -1501,6 +1530,11 @@ int cmd_clone(int argc,
1530 if (!our_head_points_at)
1531 die(_("Remote branch %s not found in upstream %s"),
1532 option_branch, remote_name);
1533 + } else if (option_rev) {
1534 + our_head_points_at = mapped_refs;
1535 + if (!our_head_points_at)
1536 + die(_("Remote revision %s not found in upstream %s"),
1537 + option_rev, remote_name);
1538 } else if (remote_head_points_at) {
1539 our_head_points_at = remote_head_points_at;
1540 } else if (remote_head) {
@@ -1539,8 +1573,9 @@ int cmd_clone(int argc,
1573 free(to_free);
1574 }
1575
1542 - write_refspec_config(src_ref_prefix, our_head_points_at,
1543 - remote_head_points_at, &branch_top);
1576 + if (!option_rev)
1577 + write_refspec_config(src_ref_prefix, our_head_points_at,
1578 + remote_head_points_at, &branch_top);
1579
1580 if (filter_options.choice)
1581 partial_clone_register(remote_name, &filter_options);
@@ -1556,7 +1591,7 @@ int cmd_clone(int argc,
1591 branch_top.buf, reflog_msg.buf, transport,
1592 !is_local);
1593
1559 - update_head(our_head_points_at, remote_head, unborn_head, reflog_msg.buf);
1594 + update_head(&opts, our_head_points_at, remote_head, unborn_head, reflog_msg.buf);
1595
1596 /*
1597 * We want to show progress for recursive submodule clones iff
t/meson.build
+1
@@ -721,6 +721,7 @@ integration_tests = [
721 't5617-clone-submodules-remote.sh',
722 't5618-alternate-refs.sh',
723 't5619-clone-local-ambiguous-transport.sh',
724 + 't5621-clone-revision.sh',
725 't5700-protocol-v1.sh',
726 't5701-git-serve.sh',
727 't5702-protocol-v2.sh',
t/t5621-clone-revision.sh new
+122
@@ -0,0 +1,122 @@
1 +#!/bin/sh
2 +
3 +test_description='tests for git clone --revision'
4 +GIT_TEST_DEFAULT_INITIAL_BRANCH_NAME=main
5 +export GIT_TEST_DEFAULT_INITIAL_BRANCH_NAME
6 +
7 +. ./test-lib.sh
8 +
9 +test_expect_success 'setup' '
10 + test_commit --no-tag "initial commit" README "Hello" &&
11 + test_commit --annotate "second commit" README "Hello world" v1.0 &&
12 + test_commit --no-tag "third commit" README "Hello world!" &&
13 + git switch -c feature v1.0 &&
14 + test_commit --no-tag "feature commit" README "Hello world!" &&
15 + git switch main
16 +'
17 +
18 +test_expect_success 'clone with --revision being a branch' '
19 + test_when_finished "rm -rf dst" &&
20 + git clone --revision=refs/heads/feature . dst &&
21 + git rev-parse refs/heads/feature >expect &&
22 + git -C dst rev-parse HEAD >actual &&
23 + test_must_fail git -C dst symbolic-ref -q HEAD >/dev/null &&
24 + test_cmp expect actual &&
25 + git -C dst for-each-ref refs >expect &&
26 + test_must_be_empty expect &&
27 + test_must_fail git -C dst config remote.origin.fetch
28 +'
29 +
30 +test_expect_success 'clone with --depth and --revision being a branch' '
31 + test_when_finished "rm -rf dst" &&
32 + git clone --no-local --depth=1 --revision=refs/heads/feature . dst &&
33 + git rev-parse refs/heads/feature >expect &&
34 + git -C dst rev-parse HEAD >actual &&
35 + test_must_fail git -C dst symbolic-ref -q HEAD >/dev/null &&
36 + test_cmp expect actual &&
37 + git -C dst for-each-ref refs >expect &&
38 + test_must_be_empty expect &&
39 + test_must_fail git -C dst config remote.origin.fetch &&
40 + git -C dst rev-list HEAD >actual &&
41 + test_line_count = 1 actual
42 +'
43 +
44 +test_expect_success 'clone with --revision being a tag' '
45 + test_when_finished "rm -rf dst" &&
46 + git clone --revision=refs/tags/v1.0 . dst &&
47 + git rev-parse refs/tags/v1.0^{} >expect &&
48 + git -C dst rev-parse HEAD >actual &&
49 + test_must_fail git -C dst symbolic-ref -q HEAD >/dev/null &&
50 + test_cmp expect actual &&
51 + git -C dst for-each-ref refs >expect &&
52 + test_must_be_empty expect &&
53 + test_must_fail git -C dst config remote.origin.fetch
54 +'
55 +
56 +test_expect_success 'clone with --revision being HEAD' '
57 + test_when_finished "rm -rf dst" &&
58 + git clone --revision=HEAD . dst &&
59 + git rev-parse HEAD >expect &&
60 + git -C dst rev-parse HEAD >actual &&
61 + test_must_fail git -C dst symbolic-ref -q HEAD >/dev/null &&
62 + test_cmp expect actual &&
63 + git -C dst for-each-ref refs >expect &&
64 + test_must_be_empty expect &&
65 + test_must_fail git -C dst config remote.origin.fetch
66 +'
67 +
68 +test_expect_success 'clone with --revision being a raw commit hash' '
69 + test_when_finished "rm -rf dst" &&
70 + oid=$(git rev-parse refs/heads/feature) &&
71 + git clone --revision=$oid . dst &&
72 + echo $oid >expect &&
73 + git -C dst rev-parse HEAD >actual &&
74 + test_must_fail git -C dst symbolic-ref -q HEAD >/dev/null &&
75 + test_cmp expect actual &&
76 + git -C dst for-each-ref refs >expect &&
77 + test_must_be_empty expect &&
78 + test_must_fail git -C dst config remote.origin.fetch
79 +'
80 +
81 +test_expect_success 'clone with --revision and --bare' '
82 + test_when_finished "rm -rf dst" &&
83 + git clone --revision=refs/heads/main --bare . dst &&
84 + oid=$(git rev-parse refs/heads/main) &&
85 + git -C dst cat-file -t $oid >actual &&
86 + echo "commit" >expect &&
87 + test_cmp expect actual &&
88 + git -C dst for-each-ref refs >expect &&
89 + test_must_be_empty expect &&
90 + test_must_fail git -C dst config remote.origin.fetch
91 +'
92 +
93 +test_expect_success 'clone with --revision being a short raw commit hash' '
94 + test_when_finished "rm -rf dst" &&
95 + oid=$(git rev-parse --short refs/heads/feature) &&
96 + test_must_fail git clone --revision=$oid . dst 2>err &&
97 + test_grep "fatal: Remote revision $oid not found in upstream origin" err
98 +'
99 +
100 +test_expect_success 'clone with --revision being a tree hash' '
101 + test_when_finished "rm -rf dst" &&
102 + oid=$(git rev-parse refs/heads/feature^{tree}) &&
103 + test_must_fail git clone --revision=$oid . dst 2>err &&
104 + test_grep "error: object $oid is a tree, not a commit" err
105 +'
106 +
107 +test_expect_success 'clone with --revision being the parent of a ref fails' '
108 + test_when_finished "rm -rf dst" &&
109 + test_must_fail git clone --revision=refs/heads/main^ . dst
110 +'
111 +
112 +test_expect_success 'clone with --revision and --branch fails' '
113 + test_when_finished "rm -rf dst" &&
114 + test_must_fail git clone --revision=refs/heads/main --branch=main . dst
115 +'
116 +
117 +test_expect_success 'clone with --revision and --mirror fails' '
118 + test_when_finished "rm -rf dst" &&
119 + test_must_fail git clone --revision=refs/heads/main --mirror . dst
120 +'
121 +
122 +test_done