Git fork

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>

authored by

Toon Claes and committed by
Junio C Hamano
33785562 9144b936

+178 -11
+9
Documentation/git-clone.txt
··· 221 221 `--branch` can also take tags and detaches the `HEAD` at that commit 222 222 in the resulting repository. 223 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 + 224 233 `-u` _<upload-pack>_:: 225 234 `--upload-pack` _<upload-pack>_:: 226 235 When given, and the repository to clone from is accessed
+46 -11
builtin/clone.c
··· 59 59 60 60 struct clone_opts { 61 61 int wants_head; 62 + int detach; 62 63 }; 63 64 #define CLONE_OPTS_INIT { \ 64 65 .wants_head = 1 /* default enabled */ \ ··· 565 566 } 566 567 } 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, 569 570 const char *unborn, const char *msg) 570 571 { 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)) { 573 574 /* Local default branch link */ 574 575 if (refs_update_symref(get_main_ref_store(the_repository), "HEAD", our->name, NULL) < 0) 575 576 die(_("unable to update HEAD")); ··· 580 581 install_branch_config(0, head, remote_name, our->name); 581 582 } 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 + 585 587 /* --branch specifies a non-branch (i.e. tags), detach HEAD */ 586 588 refs_update_ref(get_main_ref_store(the_repository), msg, 587 589 "HEAD", &c->object.oid, NULL, REF_NO_DEREF, ··· 900 902 int option_filter_submodules = -1; /* unspecified */ 901 903 struct string_list server_options = STRING_LIST_INIT_NODUP; 902 904 const char *bundle_uri = NULL; 905 + char *option_rev = NULL; 903 906 904 907 struct clone_opts opts = CLONE_OPTS_INIT; 905 908 ··· 943 946 N_("use <name> instead of 'origin' to track upstream")), 944 947 OPT_STRING('b', "branch", &option_branch, N_("branch"), 945 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")), 946 951 OPT_STRING('u', "upload-pack", &option_upload_pack, N_("path"), 947 952 N_("path to git-upload-pack on the remote")), 948 953 OPT_STRING(0, "depth", &option_depth, N_("depth"), ··· 1279 1284 strbuf_addstr(&branch_top, src_ref_prefix); 1280 1285 1281 1286 git_config_set("core.bare", "true"); 1282 - } else { 1287 + } else if (!option_rev) { 1283 1288 strbuf_addf(&branch_top, "refs/remotes/%s/", remote_name); 1284 1289 } 1285 1290 ··· 1298 1303 1299 1304 remote = remote_get_early(remote_name); 1300 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); 1303 1309 1304 1310 path = get_repo_path(remote->url.v[0], &is_bundle); 1305 1311 is_local = option_local != 0 && path && !is_bundle; ··· 1342 1348 1343 1349 transport_set_option(transport, TRANS_OPT_KEEP, "yes"); 1344 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 + 1345 1356 if (reject_shallow) 1346 1357 transport_set_option(transport, TRANS_OPT_REJECT_SHALLOW, "1"); 1347 1358 if (option_depth) ··· 1378 1389 if (transport->smart_options && !deepen && !filter_options.choice) 1379 1390 transport->smart_options->check_self_contained_and_connected = 1; 1380 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 + } 1382 1400 1383 1401 if (option_tags || option_branch) 1384 1402 /* ··· 1392 1410 if (option_branch) 1393 1411 expand_ref_prefix(&transport_ls_refs_options.ref_prefixes, 1394 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"); 1395 1424 1396 1425 refs = transport_get_remote_refs(transport, &transport_ls_refs_options); 1397 1426 ··· 1501 1530 if (!our_head_points_at) 1502 1531 die(_("Remote branch %s not found in upstream %s"), 1503 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); 1504 1538 } else if (remote_head_points_at) { 1505 1539 our_head_points_at = remote_head_points_at; 1506 1540 } else if (remote_head) { ··· 1539 1573 free(to_free); 1540 1574 } 1541 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); 1544 1579 1545 1580 if (filter_options.choice) 1546 1581 partial_clone_register(remote_name, &filter_options); ··· 1556 1591 branch_top.buf, reflog_msg.buf, transport, 1557 1592 !is_local); 1558 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); 1560 1595 1561 1596 /* 1562 1597 * We want to show progress for recursive submodule clones iff
+1
t/meson.build
··· 721 721 't5617-clone-submodules-remote.sh', 722 722 't5618-alternate-refs.sh', 723 723 't5619-clone-local-ambiguous-transport.sh', 724 + 't5621-clone-revision.sh', 724 725 't5700-protocol-v1.sh', 725 726 't5701-git-serve.sh', 726 727 't5702-protocol-v2.sh',
+122
t/t5621-clone-revision.sh
··· 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