Skip to content

Commit 3378556

Browse files
To1negitster
authored andcommitted
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 <[email protected]> Acked-by: Patrick Steinhardt <[email protected]> [jc: removed unnecessary TEST_PASSES_SANITIZE_LEAK from the test] Signed-off-by: Junio C Hamano <[email protected]>
1 parent 9144b93 commit 3378556

File tree

4 files changed

+178
-11
lines changed

4 files changed

+178
-11
lines changed

Documentation/git-clone.txt

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -221,6 +221,15 @@ objects from the source repository into a pack in the cloned repository.
221221
`--branch` can also take tags and detaches the `HEAD` at that commit
222222
in the resulting repository.
223223

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+
224233
`-u` _<upload-pack>_::
225234
`--upload-pack` _<upload-pack>_::
226235
When given, and the repository to clone from is accessed

builtin/clone.c

Lines changed: 46 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@
5959

6060
struct clone_opts {
6161
int wants_head;
62+
int detach;
6263
};
6364
#define CLONE_OPTS_INIT { \
6465
.wants_head = 1 /* default enabled */ \
@@ -565,11 +566,11 @@ static void update_remote_refs(const struct ref *refs,
565566
}
566567
}
567568

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,
569570
const char *unborn, const char *msg)
570571
{
571572
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)) {
573574
/* Local default branch link */
574575
if (refs_update_symref(get_main_ref_store(the_repository), "HEAD", our->name, NULL) < 0)
575576
die(_("unable to update HEAD"));
@@ -580,8 +581,9 @@ static void update_head(const struct ref *our, const struct ref *remote,
580581
install_branch_config(0, head, remote_name, our->name);
581582
}
582583
} 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+
585587
/* --branch specifies a non-branch (i.e. tags), detach HEAD */
586588
refs_update_ref(get_main_ref_store(the_repository), msg,
587589
"HEAD", &c->object.oid, NULL, REF_NO_DEREF,
@@ -900,6 +902,7 @@ int cmd_clone(int argc,
900902
int option_filter_submodules = -1; /* unspecified */
901903
struct string_list server_options = STRING_LIST_INIT_NODUP;
902904
const char *bundle_uri = NULL;
905+
char *option_rev = NULL;
903906

904907
struct clone_opts opts = CLONE_OPTS_INIT;
905908

@@ -943,6 +946,8 @@ int cmd_clone(int argc,
943946
N_("use <name> instead of 'origin' to track upstream")),
944947
OPT_STRING('b', "branch", &option_branch, N_("branch"),
945948
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")),
946951
OPT_STRING('u', "upload-pack", &option_upload_pack, N_("path"),
947952
N_("path to git-upload-pack on the remote")),
948953
OPT_STRING(0, "depth", &option_depth, N_("depth"),
@@ -1279,7 +1284,7 @@ int cmd_clone(int argc,
12791284
strbuf_addstr(&branch_top, src_ref_prefix);
12801285

12811286
git_config_set("core.bare", "true");
1282-
} else {
1287+
} else if (!option_rev) {
12831288
strbuf_addf(&branch_top, "refs/remotes/%s/", remote_name);
12841289
}
12851290

@@ -1298,8 +1303,9 @@ int cmd_clone(int argc,
12981303

12991304
remote = remote_get_early(remote_name);
13001305

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);
13031309

13041310
path = get_repo_path(remote->url.v[0], &is_bundle);
13051311
is_local = option_local != 0 && path && !is_bundle;
@@ -1342,6 +1348,11 @@ int cmd_clone(int argc,
13421348

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

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+
13451356
if (reject_shallow)
13461357
transport_set_option(transport, TRANS_OPT_REJECT_SHALLOW, "1");
13471358
if (option_depth)
@@ -1378,7 +1389,14 @@ int cmd_clone(int argc,
13781389
if (transport->smart_options && !deepen && !filter_options.choice)
13791390
transport->smart_options->check_self_contained_and_connected = 1;
13801391

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+
}
13821400

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

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+
13961425
refs = transport_get_remote_refs(transport, &transport_ls_refs_options);
13971426

13981427
/*
@@ -1501,6 +1530,11 @@ int cmd_clone(int argc,
15011530
if (!our_head_points_at)
15021531
die(_("Remote branch %s not found in upstream %s"),
15031532
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);
15041538
} else if (remote_head_points_at) {
15051539
our_head_points_at = remote_head_points_at;
15061540
} else if (remote_head) {
@@ -1539,8 +1573,9 @@ int cmd_clone(int argc,
15391573
free(to_free);
15401574
}
15411575

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);
15441579

15451580
if (filter_options.choice)
15461581
partial_clone_register(remote_name, &filter_options);
@@ -1556,7 +1591,7 @@ int cmd_clone(int argc,
15561591
branch_top.buf, reflog_msg.buf, transport,
15571592
!is_local);
15581593

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);
15601595

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

t/meson.build

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -721,6 +721,7 @@ integration_tests = [
721721
't5617-clone-submodules-remote.sh',
722722
't5618-alternate-refs.sh',
723723
't5619-clone-local-ambiguous-transport.sh',
724+
't5621-clone-revision.sh',
724725
't5700-protocol-v1.sh',
725726
't5701-git-serve.sh',
726727
't5702-protocol-v2.sh',

t/t5621-clone-revision.sh

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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

0 commit comments

Comments
 (0)