@@ -7,6 +7,7 @@
#include "parse-options.h"
#include "config.h"
#include "run-command.h"
+#include "refs.h"
/*
* Remove the deepest subdirectory in the provided path string. Path must not
@@ -250,6 +251,205 @@ static int unregister_dir(void)
return res;
}
+/* printf-style interface, expects `<key>=<value>` argument */
+static int set_config(const char *fmt, ...)
+{
+ struct strbuf buf = STRBUF_INIT;
+ char *value;
+ int res;
+ va_list args;
+
+ va_start(args, fmt);
+ strbuf_vaddf(&buf, fmt, args);
+ va_end(args);
+
+ value = strchr(buf.buf, '=');
+ if (value)
+ *(value++) = '\0';
+ res = git_config_set_gently(buf.buf, value);
+ strbuf_release(&buf);
+
+ return res;
+}
+
+static char *remote_default_branch(const char *url)
+{
+ struct child_process cp = CHILD_PROCESS_INIT;
+ struct strbuf out = STRBUF_INIT;
+
+ cp.git_cmd = 1;
+ strvec_pushl(&cp.args, "ls-remote", "--symref", url, "HEAD", NULL);
+ if (!pipe_command(&cp, NULL, 0, &out, 0, NULL, 0)) {
+ const char *line = out.buf;
+
+ while (*line) {
+ const char *eol = strchrnul(line, '\n'), *p;
+ size_t len = eol - line;
+ char *branch;
+
+ if (!skip_prefix(line, "ref: ", &p) ||
+ !strip_suffix_mem(line, &len, "\tHEAD")) {
+ line = eol + (*eol == '\n');
+ continue;
+ }
+
+ eol = line + len;
+ if (skip_prefix(p, "refs/heads/", &p)) {
+ branch = xstrndup(p, eol - p);
+ strbuf_release(&out);
+ return branch;
+ }
+
+ error(_("remote HEAD is not a branch: '%.*s'"),
+ (int)(eol - p), p);
+ strbuf_release(&out);
+ return NULL;
+ }
+ }
+ warning(_("failed to get default branch name from remote; "
+ "using local default"));
+ strbuf_reset(&out);
+
+ child_process_init(&cp);
+ cp.git_cmd = 1;
+ strvec_pushl(&cp.args, "symbolic-ref", "--short", "HEAD", NULL);
+ if (!pipe_command(&cp, NULL, 0, &out, 0, NULL, 0)) {
+ strbuf_trim(&out);
+ return strbuf_detach(&out, NULL);
+ }
+
+ strbuf_release(&out);
+ error(_("failed to get default branch name"));
+ return NULL;
+}
+
+static int cmd_clone(int argc, const char **argv)
+{
+ const char *branch = NULL;
+ int full_clone = 0;
+ struct option clone_options[] = {
+ OPT_STRING('b', "branch", &branch, N_("<branch>"),
+ N_("branch to checkout after clone")),
+ OPT_BOOL(0, "full-clone", &full_clone,
+ N_("when cloning, create full working directory")),
+ OPT_END(),
+ };
+ const char * const clone_usage[] = {
+ N_("scalar clone [<options>] [--] <repo> [<dir>]"),
+ NULL
+ };
+ const char *url;
+ char *enlistment = NULL, *dir = NULL;
+ struct strbuf buf = STRBUF_INIT;
+ int res;
+
+ argc = parse_options(argc, argv, NULL, clone_options, clone_usage, 0);
+
+ if (argc == 2) {
+ url = argv[0];
+ enlistment = xstrdup(argv[1]);
+ } else if (argc == 1) {
+ url = argv[0];
+
+ strbuf_addstr(&buf, url);
+ /* Strip trailing slashes, if any */
+ while (buf.len > 0 && is_dir_sep(buf.buf[buf.len - 1]))
+ strbuf_setlen(&buf, buf.len - 1);
+ /* Strip suffix `.git`, if any */
+ strbuf_strip_suffix(&buf, ".git");
+
+ enlistment = find_last_dir_sep(buf.buf);
+ if (!enlistment) {
+ die(_("cannot deduce worktree name from '%s'"), url);
+ }
+ enlistment = xstrdup(enlistment + 1);
+ } else {
+ usage_msg_opt(_("You must specify a repository to clone."),
+ clone_usage, clone_options);
+ }
+
+ if (is_directory(enlistment))
+ die(_("directory '%s' exists already"), enlistment);
+
+ dir = xstrfmt("%s/src", enlistment);
+
+ strbuf_reset(&buf);
+ if (branch)
+ strbuf_addf(&buf, "init.defaultBranch=%s", branch);
+ else {
+ char *b = repo_default_branch_name(the_repository, 1);
+ strbuf_addf(&buf, "init.defaultBranch=%s", b);
+ free(b);
+ }
+
+ if ((res = run_git("-c", buf.buf, "init", "--", dir, NULL)))
+ goto cleanup;
+
+ if (chdir(dir) < 0) {
+ res = error_errno(_("could not switch to '%s'"), dir);
+ goto cleanup;
+ }
+
+ setup_git_directory();
+
+ /* common-main already logs `argv` */
+ trace2_def_repo(the_repository);
+
+ if (!branch && !(branch = remote_default_branch(url))) {
+ res = error(_("failed to get default branch for '%s'"), url);
+ goto cleanup;
+ }
+
+ if (set_config("remote.origin.url=%s", url) ||
+ set_config("remote.origin.fetch="
+ "+refs/heads/*:refs/remotes/origin/*") ||
+ set_config("remote.origin.promisor=true") ||
+ set_config("remote.origin.partialCloneFilter=blob:none")) {
+ res = error(_("could not configure remote in '%s'"), dir);
+ goto cleanup;
+ }
+
+ if (!full_clone &&
+ (res = run_git("sparse-checkout", "init", "--cone", NULL)))
+ goto cleanup;
+
+ if (set_recommended_config())
+ return error(_("could not configure '%s'"), dir);
+
+ if ((res = run_git("fetch", "--quiet", "origin", NULL))) {
+ warning(_("partial clone failed; attempting full clone"));
+
+ if (set_config("remote.origin.promisor") ||
+ set_config("remote.origin.partialCloneFilter")) {
+ res = error(_("could not configure for full clone"));
+ goto cleanup;
+ }
+
+ if ((res = run_git("fetch", "--quiet", "origin", NULL)))
+ goto cleanup;
+ }
+
+ if ((res = set_config("branch.%s.remote=origin", branch)))
+ goto cleanup;
+ if ((res = set_config("branch.%s.merge=refs/heads/%s",
+ branch, branch)))
+ goto cleanup;
+
+ strbuf_reset(&buf);
+ strbuf_addf(&buf, "origin/%s", branch);
+ res = run_git("checkout", "-f", "-t", buf.buf, NULL);
+ if (res)
+ goto cleanup;
+
+ res = register_dir();
+
+cleanup:
+ free(enlistment);
+ free(dir);
+ strbuf_release(&buf);
+ return res;
+}
+
static int cmd_list(int argc, const char **argv)
{
if (argc != 1)
@@ -346,6 +546,7 @@ static struct {
const char *name;
int (*fn)(int, const char **);
} builtins[] = {
+ { "clone", cmd_clone },
{ "list", cmd_list },
{ "register", cmd_register },
{ "unregister", cmd_unregister },
@@ -8,6 +8,7 @@ scalar - an opinionated repository management tool
SYNOPSIS
--------
[verse]
+scalar clone [--branch <main-branch>] [--full-clone] <url> [<enlistment>]
scalar list
scalar register [<enlistment>]
scalar unregister [<enlistment>]
@@ -29,12 +30,36 @@ an existing Git worktree with Scalar whose name is not `src`, the enlistment
will be identical to the worktree.
The `scalar` command implements various subcommands, and different options
-depending on the subcommand. With the exception of `list`, all subcommands
-expect to be run in an enlistment.
+depending on the subcommand. With the exception of `clone` and `list`, all
+subcommands expect to be run in an enlistment.
COMMANDS
--------
+Clone
+~~~~~
+
+clone [<options>] <url> [<enlistment>]::
+ Clones the specified repository, similar to linkgit:git-clone[1]. By
+ default, only commit and tree objects are cloned. Once finished, the
+ worktree is located at `<enlistment>/src`.
++
+The sparse-checkout feature is enabled (except when run with `--full-clone`)
+and the only files present are those in the top-level directory. Use
+`git sparse-checkout set` to expand the set of directories you want to see,
+or `git sparse-checkout disable` to expand to all files (see
+linkgit:git-sparse-checkout[1] for more details). You can explore the
+subdirectories outside your sparse-checkout by using `git ls-tree HEAD`.
+
+-b <name>::
+--branch <name>::
+ Instead of checking out the branch pointed to by the cloned
+ repository's HEAD, check out the `<name>` branch instead.
+
+--[no-]full-clone::
+ A sparse-checkout is initialized by default. This behavior can be
+ turned off via `--full-clone`.
+
List
~~~~
@@ -64,7 +89,7 @@ unregister [<enlistment>]::
SEE ALSO
--------
-linkgit:git-maintenance[1].
+linkgit:git-clone[1], linkgit:git-maintenance[1].
Scalar
---
@@ -10,6 +10,9 @@ PATH=$PWD/..:$PATH
. ../../../t/test-lib.sh
+GIT_TEST_MAINT_SCHEDULER="crontab:test-tool crontab ../cron.txt"
+export GIT_TEST_MAINT_SCHEDULER
+
test_expect_success 'scalar shows a usage' '
test_expect_code 129 scalar -h
'
@@ -29,4 +32,33 @@ test_expect_success 'scalar unregister' '
! grep -F "$(pwd)/vanish/src" scalar.repos
'
+test_expect_success 'set up repository to clone' '
+ test_commit first &&
+ test_commit second &&
+ test_commit third &&
+ git switch -c parallel first &&
+ mkdir -p 1/2 &&
+ test_commit 1/2/3 &&
+ git config uploadPack.allowFilter true &&
+ git config uploadPack.allowAnySHA1InWant true
+'
+
+test_expect_success 'scalar clone' '
+ second=$(git rev-parse --verify second:second.t) &&
+ scalar clone "file://$(pwd)" cloned &&
+ (
+ cd cloned/src &&
+
+ git config --get --global --fixed-value maintenance.repo \
+ "$(pwd)" &&
+
+ test_path_is_missing 1/2 &&
+ test_must_fail git rev-list --missing=print $second &&
+ git rev-list $second &&
+ git cat-file blob $second >actual &&
+ echo "second" >expect &&
+ test_cmp expect actual
+ )
+'
+
test_done