diff --git a/Documentation/config/extensions.adoc b/Documentation/config/extensions.adoc index 532456644b..2aef3315b1 100644 --- a/Documentation/config/extensions.adoc +++ b/Documentation/config/extensions.adoc @@ -73,6 +73,35 @@ relativeWorktrees::: repaired with either the `--relative-paths` option or with the `worktree.useRelativePaths` config set to `true`. +submodulePathConfig::: + This extension is for the minority of users who: ++ +-- +* Encounter errors like `refusing to create ... in another submodule's git dir` + due to a number of reasons, like case-insensitive filesystem conflicts when + creating modules named `foo` and `Foo`. +* Require more flexible submodule layouts, for example due to nested names like + `foo`, `foo/bar` and `foo/baz` not supported by the default gitdir mechanism + which uses `.git/modules/` locations, causing further conflicts. +-- ++ +When `extensions.submodulePathConfig` is enabled, the `submodule..gitdir` +config becomes the single source of truth for all submodule gitdir paths and is +automatically set for all new submodules both during clone and init operations. ++ +Git will error out if a module does not have a corresponding +`submodule..gitdir` set. ++ +Existing (pre-extension) submodules need to be migrated by adding the missing +config entries. This can be done manually, e.g. for each submodule: +`git config submodule..gitdir .git/modules/`, or via the +`git submodule--helper migrate-gitdir-configs` command which iterates over all +submodules and attempts to migrate them. ++ +The extension can be enabled automatically for new repositories by setting +`init.defaultSubmodulePathConfig` to `true`, for example by running +`git config --global init.defaultSubmodulePathConfig true`. + worktreeConfig::: If enabled, then worktrees will load config settings from the `$GIT_DIR/config.worktree` file in addition to the diff --git a/Documentation/config/init.adoc b/Documentation/config/init.adoc index e45b2a8121..7b4abdaf8b 100644 --- a/Documentation/config/init.adoc +++ b/Documentation/config/init.adoc @@ -18,3 +18,9 @@ endif::[] See `--ref-format=` in linkgit:git-init[1]. Both the command line option and the `GIT_DEFAULT_REF_FORMAT` environment variable take precedence over this config. + +init.defaultSubmodulePathConfig:: + A boolean that specifies if `git init` and `git clone` should + automatically set `extensions.submodulePathConfig` to `true`. This + allows all new repositories to automatically use the submodule path + extension. Defaults to `false` when unset. diff --git a/Documentation/config/submodule.adoc b/Documentation/config/submodule.adoc index 0672d99117..74f1659a91 100644 --- a/Documentation/config/submodule.adoc +++ b/Documentation/config/submodule.adoc @@ -52,6 +52,13 @@ submodule..active:: submodule.active config option. See linkgit:gitsubmodules[7] for details. +submodule..gitdir:: + This sets the gitdir path for submodule . This configuration is + respected when `extensions.submodulePathConfig` is enabled, otherwise it + has no effect. When enabled, this config becomes the single source of + truth for submodule gitdir paths and Git will error if it is missing. + See linkgit:git-config[1] for details. + submodule.active:: A repeated field which contains a pathspec used to match against a submodule's path to determine if the submodule is of interest to git diff --git a/builtin/credential-store.c b/builtin/credential-store.c index b74e06cc93..bc1453c6b2 100644 --- a/builtin/credential-store.c +++ b/builtin/credential-store.c @@ -7,6 +7,7 @@ #include "path.h" #include "string-list.h" #include "parse-options.h" +#include "url.h" #include "write-or-die.h" static struct lock_file credential_lock; @@ -76,12 +77,6 @@ static void rewrite_credential_file(const char *fn, struct credential *c, die_errno("unable to write credential store"); } -static int is_rfc3986_unreserved(char ch) -{ - return isalnum(ch) || - ch == '-' || ch == '_' || ch == '.' || ch == '~'; -} - static int is_rfc3986_reserved_or_unreserved(char ch) { if (is_rfc3986_unreserved(ch)) diff --git a/builtin/submodule--helper.c b/builtin/submodule--helper.c index d537ab087a..ab4a6318b7 100644 --- a/builtin/submodule--helper.c +++ b/builtin/submodule--helper.c @@ -34,6 +34,7 @@ #include "list-objects-filter-options.h" #include "wildmatch.h" #include "strbuf.h" +#include "url.h" #define OPT_QUIET (1 << 0) #define OPT_CACHED (1 << 1) @@ -435,6 +436,102 @@ struct init_cb { }; #define INIT_CB_INIT { 0 } +static int validate_and_set_submodule_gitdir(struct strbuf *gitdir_path, + const char *submodule_name) +{ + const char *value; + char *key; + + if (validate_submodule_git_dir(gitdir_path->buf, submodule_name)) + return -1; + + key = xstrfmt("submodule.%s.gitdir", submodule_name); + + /* Nothing to do if the config already exists. */ + if (!repo_config_get_string_tmp(the_repository, key, &value)) { + free(key); + return 0; + } + + if (repo_config_set_gently(the_repository, key, gitdir_path->buf)) { + free(key); + return -1; + } + + free(key); + return 0; +} + +static void create_default_gitdir_config(const char *submodule_name) +{ + struct strbuf gitdir_path = STRBUF_INIT; + struct git_hash_ctx ctx; + char hex_name_hash[GIT_MAX_HEXSZ + 1], header[128]; + unsigned char raw_name_hash[GIT_MAX_RAWSZ]; + int header_len; + + /* Case 1: try the plain module name */ + repo_git_path_append(the_repository, &gitdir_path, "modules/%s", submodule_name); + if (!validate_and_set_submodule_gitdir(&gitdir_path, submodule_name)) { + strbuf_release(&gitdir_path); + return; + } + + /* Case 2.1: Try URI-safe (RFC3986) encoding first, this fixes nested gitdirs */ + strbuf_reset(&gitdir_path); + repo_git_path_append(the_repository, &gitdir_path, "modules/"); + strbuf_addstr_urlencode(&gitdir_path, submodule_name, is_rfc3986_unreserved); + if (!validate_and_set_submodule_gitdir(&gitdir_path, submodule_name)) { + strbuf_release(&gitdir_path); + return; + } + + /* Case 2.2: Try extended uppercase URI (RFC3986) encoding, to fix case-folding */ + strbuf_reset(&gitdir_path); + repo_git_path_append(the_repository, &gitdir_path, "modules/"); + strbuf_addstr_urlencode(&gitdir_path, submodule_name, is_casefolding_rfc3986_unreserved); + if (!validate_and_set_submodule_gitdir(&gitdir_path, submodule_name)) + return; + + /* Case 2.3: Try some derived gitdir names, see if one sticks */ + for (char c = '0'; c <= '9'; c++) { + strbuf_reset(&gitdir_path); + repo_git_path_append(the_repository, &gitdir_path, "modules/"); + strbuf_addstr_urlencode(&gitdir_path, submodule_name, is_rfc3986_unreserved); + strbuf_addch(&gitdir_path, c); + if (!validate_and_set_submodule_gitdir(&gitdir_path, submodule_name)) + return; + + strbuf_reset(&gitdir_path); + repo_git_path_append(the_repository, &gitdir_path, "modules/"); + strbuf_addstr_urlencode(&gitdir_path, submodule_name, is_casefolding_rfc3986_unreserved); + strbuf_addch(&gitdir_path, c); + if (!validate_and_set_submodule_gitdir(&gitdir_path, submodule_name)) + return; + } + + /* Case 2.4: If all the above failed, try a hash of the name as a last resort */ + header_len = snprintf(header, sizeof(header), "blob %zu", strlen(submodule_name)); + the_hash_algo->init_fn(&ctx); + the_hash_algo->update_fn(&ctx, header, header_len); + the_hash_algo->update_fn(&ctx, "\0", 1); + the_hash_algo->update_fn(&ctx, submodule_name, strlen(submodule_name)); + the_hash_algo->final_fn(raw_name_hash, &ctx); + hash_to_hex_algop_r(hex_name_hash, raw_name_hash, the_hash_algo); + strbuf_reset(&gitdir_path); + repo_git_path_append(the_repository, &gitdir_path, "modules/%s", hex_name_hash); + if (!validate_and_set_submodule_gitdir(&gitdir_path, submodule_name)) { + strbuf_release(&gitdir_path); + return; + } + + /* Case 3: nothing worked, error out */ + die(_("failed to set a valid default config for 'submodule.%s.gitdir'. " + "Please ensure it is set, for example by running something like: " + "'git config submodule.%s.gitdir .git/modules/%s'"), + submodule_name, submodule_name, submodule_name); +} + static void init_submodule(const char *path, const char *prefix, const char *super_prefix, unsigned int flags) @@ -511,6 +608,10 @@ static void init_submodule(const char *path, const char *prefix, if (repo_config_set_gently(the_repository, sb.buf, upd)) die(_("Failed to register update mode for submodule path '%s'"), displaypath); } + + if (the_repository->repository_format_submodule_path_cfg) + create_default_gitdir_config(sub->name); + strbuf_release(&sb); free(displaypath); free(url); @@ -1204,6 +1305,82 @@ static int module_summary(int argc, const char **argv, const char *prefix, return ret; } +static int module_gitdir(int argc, const char **argv, const char *prefix UNUSED, + struct repository *repo) +{ + struct strbuf gitdir = STRBUF_INIT; + + if (argc != 2) + usage(_("git submodule--helper gitdir ")); + + submodule_name_to_gitdir(&gitdir, repo, argv[1]); + + printf("%s\n", gitdir.buf); + + strbuf_release(&gitdir); + return 0; +} + +static int module_migrate(int argc UNUSED, const char **argv UNUSED, + const char *prefix UNUSED, struct repository *repo) +{ + struct strbuf module_dir = STRBUF_INIT; + DIR *dir; + struct dirent *de; + int repo_version = 0; + + repo_git_path_append(repo, &module_dir, "modules/"); + + dir = opendir(module_dir.buf); + if (!dir) + die(_("could not open '%s'"), module_dir.buf); + + while ((de = readdir(dir))) { + struct strbuf gitdir_path = STRBUF_INIT; + char *key; + const char *value; + + if (is_dot_or_dotdot(de->d_name)) + continue; + + strbuf_addf(&gitdir_path, "%s/%s", module_dir.buf, de->d_name); + if (!is_git_directory(gitdir_path.buf)) { + strbuf_release(&gitdir_path); + continue; + } + strbuf_release(&gitdir_path); + + key = xstrfmt("submodule.%s.gitdir", de->d_name); + if (!repo_config_get_string_tmp(repo, key, &value)) { + /* Already has a gitdir config, nothing to do. */ + free(key); + continue; + } + free(key); + + create_default_gitdir_config(de->d_name); + } + + closedir(dir); + strbuf_release(&module_dir); + + repo_config_get_int(the_repository, "core.repositoryformatversion", &repo_version); + if (repo_version == 0 && + repo_config_set_gently(repo, "core.repositoryformatversion", "1")) + die(_("could not set core.repositoryformatversion to 1. " + "Please set it for migration to work, for example: " + "git config core.repositoryformatversion 1")); + + if (repo_config_set_gently(repo, "extensions.submodulePathConfig", "true")) + die(_("could not enable submodulePathConfig extension. It is required " + "for migration to work. Please enable it in the root repo: " + "git config extensions.submodulePathConfig true")); + + repo->repository_format_submodule_path_cfg = 1; + + return 0; +} + struct sync_cb { const char *prefix; const char *super_prefix; @@ -1699,10 +1876,6 @@ static int clone_submodule(const struct module_clone_data *clone_data, clone_data_path = to_free = xstrfmt("%s/%s", repo_get_work_tree(the_repository), clone_data->path); - if (validate_submodule_git_dir(sm_gitdir, clone_data->name) < 0) - die(_("refusing to create/use '%s' in another submodule's " - "git dir"), sm_gitdir); - if (!file_exists(sm_gitdir)) { if (clone_data->require_init && !stat(clone_data_path, &st) && !is_empty_dir(clone_data_path)) @@ -1789,8 +1962,9 @@ static int clone_submodule(const struct module_clone_data *clone_data, char *head = xstrfmt("%s/HEAD", sm_gitdir); unlink(head); free(head); - die(_("refusing to create/use '%s' in another submodule's " - "git dir"), sm_gitdir); + die(_("refusing to create/use '%s' in another submodule's git dir. " + "Enabling extensions.submodulePathConfig should fix this."), + sm_gitdir); } connect_work_tree_and_git_dir(clone_data_path, sm_gitdir, 0); @@ -3190,13 +3364,13 @@ static void append_fetch_remotes(struct strbuf *msg, const char *git_dir_path) static int add_submodule(const struct add_data *add_data) { - char *submod_gitdir_path; struct module_clone_data clone_data = MODULE_CLONE_DATA_INIT; struct string_list reference = STRING_LIST_INIT_NODUP; int ret = -1; /* perhaps the path already exists and is already a git repo, else clone it */ if (is_directory(add_data->sm_path)) { + char *submod_gitdir_path; struct strbuf sm_path = STRBUF_INIT; strbuf_addstr(&sm_path, add_data->sm_path); submod_gitdir_path = xstrfmt("%s/.git", add_data->sm_path); @@ -3210,10 +3384,11 @@ static int add_submodule(const struct add_data *add_data) free(submod_gitdir_path); } else { struct child_process cp = CHILD_PROCESS_INIT; + struct strbuf submod_gitdir = STRBUF_INIT; - submod_gitdir_path = xstrfmt(".git/modules/%s", add_data->sm_name); + submodule_name_to_gitdir(&submod_gitdir, the_repository, add_data->sm_name); - if (is_directory(submod_gitdir_path)) { + if (is_directory(submod_gitdir.buf)) { if (!add_data->force) { struct strbuf msg = STRBUF_INIT; char *die_msg; @@ -3222,8 +3397,8 @@ static int add_submodule(const struct add_data *add_data) "locally with remote(s):\n"), add_data->sm_name); - append_fetch_remotes(&msg, submod_gitdir_path); - free(submod_gitdir_path); + append_fetch_remotes(&msg, submod_gitdir.buf); + strbuf_release(&submod_gitdir); strbuf_addf(&msg, _("If you want to reuse this local git " "directory instead of cloning again from\n" @@ -3241,7 +3416,7 @@ static int add_submodule(const struct add_data *add_data) "submodule '%s'\n"), add_data->sm_name); } } - free(submod_gitdir_path); + strbuf_release(&submod_gitdir); clone_data.prefix = add_data->prefix; clone_data.path = add_data->sm_path; @@ -3569,6 +3744,9 @@ static int module_add(int argc, const char **argv, const char *prefix, add_data.progress = !!progress; add_data.dissociate = !!dissociate; + if (the_repository->repository_format_submodule_path_cfg) + create_default_gitdir_config(add_data.sm_name); + if (add_submodule(&add_data)) goto cleanup; configure_added_submodule(&add_data); @@ -3594,6 +3772,8 @@ int cmd_submodule__helper(int argc, NULL }; struct option options[] = { + OPT_SUBCOMMAND("migrate-gitdir-configs", &fn, module_migrate), + OPT_SUBCOMMAND("gitdir", &fn, module_gitdir), OPT_SUBCOMMAND("clone", &fn, module_clone), OPT_SUBCOMMAND("add", &fn, module_add), OPT_SUBCOMMAND("update", &fn, module_update), diff --git a/repository.c b/repository.c index c7e75215ac..46a7c99930 100644 --- a/repository.c +++ b/repository.c @@ -281,6 +281,7 @@ int repo_init(struct repository *repo, repo->repository_format_worktree_config = format.worktree_config; repo->repository_format_relative_worktrees = format.relative_worktrees; repo->repository_format_precious_objects = format.precious_objects; + repo->repository_format_submodule_path_cfg = format.submodule_path_cfg; /* take ownership of format.partial_clone */ repo->repository_format_partial_clone = format.partial_clone; diff --git a/repository.h b/repository.h index 6063c4b846..7141237f97 100644 --- a/repository.h +++ b/repository.h @@ -165,6 +165,7 @@ struct repository { int repository_format_worktree_config; int repository_format_relative_worktrees; int repository_format_precious_objects; + int repository_format_submodule_path_cfg; /* Indicate if a repository has a different 'commondir' from 'gitdir' */ unsigned different_commondir:1; diff --git a/setup.c b/setup.c index 3a6a048620..0378483b69 100644 --- a/setup.c +++ b/setup.c @@ -686,6 +686,9 @@ static enum extension_result handle_extension(const char *var, } else if (!strcmp(ext, "relativeworktrees")) { data->relative_worktrees = git_config_bool(var, value); return EXTENSION_OK; + } else if (!strcmp(ext, "submodulepathconfig")) { + data->submodule_path_cfg = git_config_bool(var, value); + return EXTENSION_OK; } return EXTENSION_UNKNOWN; } @@ -1947,6 +1950,8 @@ const char *setup_git_directory_gently(int *nongit_ok) repo_fmt.worktree_config; the_repository->repository_format_relative_worktrees = repo_fmt.relative_worktrees; + the_repository->repository_format_submodule_path_cfg = + repo_fmt.submodule_path_cfg; /* take ownership of repo_fmt.partial_clone */ the_repository->repository_format_partial_clone = repo_fmt.partial_clone; @@ -2045,6 +2050,8 @@ void check_repository_format(struct repository_format *fmt) fmt->ref_storage_format); the_repository->repository_format_worktree_config = fmt->worktree_config; + the_repository->repository_format_submodule_path_cfg = + fmt->submodule_path_cfg; the_repository->repository_format_relative_worktrees = fmt->relative_worktrees; the_repository->repository_format_partial_clone = @@ -2303,6 +2310,7 @@ void initialize_repository_version(int hash_algo, { struct strbuf repo_version = STRBUF_INIT; int target_version = GIT_REPO_VERSION; + int default_submodule_path_config = 0; /* * Note that we initialize the repository version to 1 when the ref @@ -2341,6 +2349,15 @@ void initialize_repository_version(int hash_algo, clear_repository_format(&repo_fmt); } + repo_config_get_bool(the_repository, "init.defaultSubmodulePathConfig", + &default_submodule_path_config); + if (default_submodule_path_config) { + /* extensions.submodulepathconfig requires at least version 1 */ + if (target_version == 0) + target_version = 1; + repo_config_set(the_repository, "extensions.submodulepathconfig", "true"); + } + strbuf_addf(&repo_version, "%d", target_version); repo_config_set(the_repository, "core.repositoryformatversion", repo_version.buf); diff --git a/setup.h b/setup.h index d55dcc6608..0738dec244 100644 --- a/setup.h +++ b/setup.h @@ -167,6 +167,7 @@ struct repository_format { char *partial_clone; /* value of extensions.partialclone */ int worktree_config; int relative_worktrees; + int submodule_path_cfg; int is_bare; int hash_algo; int compat_hash_algo; diff --git a/submodule.c b/submodule.c index 40a5c6fb9d..8c17da6a5a 100644 --- a/submodule.c +++ b/submodule.c @@ -31,6 +31,8 @@ #include "commit-reach.h" #include "read-cache-ll.h" #include "setup.h" +#include "advice.h" +#include "url.h" static int config_update_recurse_submodules = RECURSE_SUBMODULES_OFF; static int initialized_fetch_ref_tips; @@ -2158,19 +2160,15 @@ int submodule_move_head(const char *path, const char *super_prefix, if (validate_submodule_git_dir(git_dir, sub->name) < 0) die(_("refusing to create/use '%s' in " - "another submodule's git dir"), - git_dir); + "another submodule's git dir. " + "Enabling extensions.submodulePathConfig " + "should fix this."), git_dir); free(git_dir); } } else { struct strbuf gitdir = STRBUF_INIT; submodule_name_to_gitdir(&gitdir, the_repository, sub->name); - if (validate_submodule_git_dir(gitdir.buf, - sub->name) < 0) - die(_("refusing to create/use '%s' in another " - "submodule's git dir"), - gitdir.buf); connect_work_tree_and_git_dir(path, gitdir.buf, 0); strbuf_release(&gitdir); @@ -2250,12 +2248,155 @@ out: return ret; } -int validate_submodule_git_dir(char *git_dir, const char *submodule_name) +static int check_casefolding_conflict(const char *git_dir, + const char *submodule_name, + const bool suffixes_match) +{ + char *p, *modules_dir = xstrdup(git_dir); + struct dirent *de; + DIR *dir = NULL; + int ret = 0; + + if ((p = find_last_dir_sep(modules_dir))) + *p = '\0'; + + /* No conflict is possible if modules_dir doesn't exist (first clone) */ + if (!is_directory(modules_dir)) + goto cleanup; + + dir = opendir(modules_dir); + if (!dir) { + ret = -1; + goto cleanup; + } + + /* Check for another directory under .git/modules that differs only in case. */ + while ((de = readdir(dir))) { + if (!strcmp(de->d_name, ".") || !strcmp(de->d_name, "..")) + continue; + + if ((suffixes_match || is_git_directory(git_dir)) && + !strcasecmp(de->d_name, submodule_name) && + strcmp(de->d_name, submodule_name)) { + ret = -1; /* collision found */ + break; + } + } + +cleanup: + if (dir) + closedir(dir); + free(modules_dir); + return ret; +} + +struct submodule_from_gitdir_cb { + const char *gitdir; + const char *submodule_name; + bool conflict_found; +}; + +static int find_conflict_by_gitdir_cb(const char *var, const char *value, + const struct config_context *ctx UNUSED, void *data) +{ + struct submodule_from_gitdir_cb *cb = data; + const char *submodule_name_start; + size_t submodule_name_len; + const char *suffix = ".gitdir"; + size_t suffix_len = strlen(suffix); + + if (!skip_prefix(var, "submodule.", &submodule_name_start)) + return 0; + + /* Check if submodule_name_start ends with ".gitdir" */ + submodule_name_len = strlen(submodule_name_start); + if (submodule_name_len < suffix_len || + strcmp(submodule_name_start + submodule_name_len - suffix_len, suffix) != 0) + return 0; /* Does not end with ".gitdir" */ + + submodule_name_len -= suffix_len; + + /* + * A conflict happens if: + * 1. The submodule names are different and + * 2. The gitdir paths resolve to the same absolute path + */ + if (value && strncmp(cb->submodule_name, submodule_name_start, submodule_name_len)) { + char *abs_path_cb = absolute_pathdup(cb->gitdir); + char *abs_path_value = absolute_pathdup(value); + + cb->conflict_found = !strcmp(abs_path_cb, abs_path_value); + + free(abs_path_cb); + free(abs_path_value); + } + + return cb->conflict_found; +} + +static bool submodule_conflicts_with_existing(const char *gitdir, const char *submodule_name) +{ + struct submodule_from_gitdir_cb cb = { 0 }; + cb.submodule_name = submodule_name; + cb.gitdir = gitdir; + + /* Find conflicts with existing repo gitdir configs */ + repo_config(the_repository, find_conflict_by_gitdir_cb, &cb); + + return cb.conflict_found; +} + +/* + * Encoded gitdir validation, only used when extensions.submodulePathConfig is enabled. + * This does not print errors like the non-encoded version, because encoding is supposed + * to mitigate / fix all these. + */ +static int validate_submodule_encoded_git_dir(char *git_dir, const char *submodule_name) +{ + const char *modules_marker = "/modules/"; + char *p = git_dir, *last_submodule_name = NULL; + int config_ignorecase = 0; + + if (!the_repository->repository_format_submodule_path_cfg) + BUG("validate_submodule_encoded_git_dir() must be called with " + "extensions.submodulePathConfig enabled."); + + /* Find the last submodule name in the gitdir path (modules can be nested). */ + while ((p = strstr(p, modules_marker))) { + last_submodule_name = p + strlen(modules_marker); + p++; + } + + /* Prevent the use of '/' in encoded names */ + if (!last_submodule_name || strchr(last_submodule_name, '/')) + return -1; + + /* Prevent conflicts with existing submodule gitdirs */ + if (is_git_directory(git_dir) && + submodule_conflicts_with_existing(git_dir, submodule_name)) + return -1; + + /* Prevent conflicts on case-folding filesystems */ + repo_config_get_bool(the_repository, "core.ignorecase", &config_ignorecase); + if (ignore_case || config_ignorecase) { + bool suffixes_match = !strcmp(last_submodule_name, submodule_name); + return check_casefolding_conflict(git_dir, submodule_name, + suffixes_match); + } + + return 0; +} + +static int validate_submodule_legacy_git_dir(char *git_dir, const char *submodule_name) { size_t len = strlen(git_dir), suffix_len = strlen(submodule_name); char *p; int ret = 0; + if (the_repository->repository_format_submodule_path_cfg) + BUG("validate_submodule_git_dir() must be called with " + "extensions.submodulePathConfig disabled."); + if (len <= suffix_len || (p = git_dir + len - suffix_len)[-1] != '/' || strcmp(p, submodule_name)) BUG("submodule name '%s' not a suffix of git dir '%s'", @@ -2291,6 +2432,14 @@ int validate_submodule_git_dir(char *git_dir, const char *submodule_name) return 0; } +int validate_submodule_git_dir(char *git_dir, const char *submodule_name) +{ + if (!the_repository->repository_format_submodule_path_cfg) + return validate_submodule_legacy_git_dir(git_dir, submodule_name); + + return validate_submodule_encoded_git_dir(git_dir, submodule_name); +} + int validate_submodule_path(const char *path) { char *p = xstrdup(path); @@ -2349,9 +2498,6 @@ static void relocate_single_git_dir_into_superproject(const char *path, die(_("could not lookup name for submodule '%s'"), path); submodule_name_to_gitdir(&new_gitdir, the_repository, sub->name); - if (validate_submodule_git_dir(new_gitdir.buf, sub->name) < 0) - die(_("refusing to move '%s' into an existing git dir"), - real_old_git_dir); if (safe_create_leading_directories_const(the_repository, new_gitdir.buf) < 0) die(_("could not create directory '%s'"), new_gitdir.buf); real_new_git_dir = real_pathdup(new_gitdir.buf, 1); @@ -2578,26 +2724,37 @@ cleanup: void submodule_name_to_gitdir(struct strbuf *buf, struct repository *r, const char *submodule_name) { - /* - * NEEDSWORK: The current way of mapping a submodule's name to - * its location in .git/modules/ has problems with some naming - * schemes. For example, if a submodule is named "foo" and - * another is named "foo/bar" (whether present in the same - * superproject commit or not - the problem will arise if both - * superproject commits have been checked out at any point in - * time), or if two submodule names only have different cases in - * a case-insensitive filesystem. - * - * There are several solutions, including encoding the path in - * some way, introducing a submodule..gitdir config in - * .git/config (not .gitmodules) that allows overriding what the - * gitdir of a submodule would be (and teach Git, upon noticing - * a clash, to automatically determine a non-clashing name and - * to write such a config), or introducing a - * submodule..gitdir config in .gitmodules that repo - * administrators can explicitly set. Nothing has been decided, - * so for now, just append the name at the end of the path. - */ - repo_git_path_append(r, buf, "modules/"); - strbuf_addstr(buf, submodule_name); + if (!r->repository_format_submodule_path_cfg) { + /* + * If extensions.submodulePathConfig is disabled, + * continue to use the plain path. + */ + repo_git_path_append(r, buf, "modules/%s", submodule_name); + } else { + const char *gitdir; + char *key; + int ret; + + /* Otherwise the extension is enabled, so use the gitdir config. */ + key = xstrfmt("submodule.%s.gitdir", submodule_name); + ret = repo_config_get_string_tmp(r, key, &gitdir); + FREE_AND_NULL(key); + + if (ret) + die(_("the 'submodule.%s.gitdir' config does not exist for module '%s'. " + "Please ensure it is set, for example by running something like: " + "'git config submodule.%s.gitdir .git/modules/%s'. For details " + "see the extensions.submodulePathConfig documentation."), + submodule_name, submodule_name, submodule_name, submodule_name); + + strbuf_addstr(buf, gitdir); + } + + /* validate because users might have modified the config */ + if (validate_submodule_git_dir(buf->buf, submodule_name)) { + advise(_("enabling extensions.submodulePathConfig might fix the " + "following error, if it's not already enabled.")); + die(_("refusing to create/use '%s' in another submodule's " + " git dir."), buf->buf); + } } diff --git a/t/lib-verify-submodule-gitdir-path.sh b/t/lib-verify-submodule-gitdir-path.sh new file mode 100644 index 0000000000..4e0cfdc605 --- /dev/null +++ b/t/lib-verify-submodule-gitdir-path.sh @@ -0,0 +1,24 @@ +# Helper to verify if repo $1 contains a submodule named $2 with gitdir path $3 + +# This does not check filesystem existence. That is done in submodule.c via the +# submodule_name_to_gitdir() API which this helper ends up calling. The gitdirs +# might or might not exist (e.g. when adding a new submodule), so this only +# checks the expected configuration path, which might be overridden by the user. + +verify_submodule_gitdir_path () { + repo="$1" && + name="$2" && + path="$3" && + ( + cd "$repo" && + # Compute expected absolute path + expected="$(git rev-parse --git-common-dir)/$path" && + expected="$(test-tool path-utils real_path "$expected")" && + # Compute actual absolute path + actual="$(git submodule--helper gitdir "$name")" && + actual="$(test-tool path-utils real_path "$actual")" && + echo "$expected" >expect && + echo "$actual" >actual && + test_cmp expect actual + ) +} diff --git a/t/meson.build b/t/meson.build index 459c52a489..0b1de3251a 100644 --- a/t/meson.build +++ b/t/meson.build @@ -887,6 +887,7 @@ integration_tests = [ 't7422-submodule-output.sh', 't7423-submodule-symlinks.sh', 't7424-submodule-mixed-ref-formats.sh', + 't7425-submodule-gitdir-path-extension.sh', 't7450-bad-git-dotfiles.sh', 't7500-commit-template-squash-signoff.sh', 't7501-commit-basic-functionality.sh', diff --git a/t/t7425-submodule-gitdir-path-extension.sh b/t/t7425-submodule-gitdir-path-extension.sh new file mode 100755 index 0000000000..50d618045b --- /dev/null +++ b/t/t7425-submodule-gitdir-path-extension.sh @@ -0,0 +1,532 @@ +#!/bin/sh + +test_description='submodulePathConfig extension works as expected' + +. ./test-lib.sh +. "$TEST_DIRECTORY"/lib-verify-submodule-gitdir-path.sh + +test_expect_success 'setup: allow file protocol' ' + git config --global protocol.file.allow always +' + +test_expect_success 'create repo with mixed extension submodules' ' + git init -b main legacy-sub && + test_commit -C legacy-sub legacy-initial && + legacy_rev=$(git -C legacy-sub rev-parse HEAD) && + + git init -b main new-sub && + test_commit -C new-sub new-initial && + new_rev=$(git -C new-sub rev-parse HEAD) && + + git init -b main main && + ( + cd main && + git submodule add ../legacy-sub legacy && + test_commit legacy-sub && + + # trigger the "die_path_inside_submodule" check + test_must_fail git submodule add ../new-sub "legacy/nested" && + + git config core.repositoryformatversion 1 && + git config extensions.submodulePathConfig true && + + git submodule add ../new-sub "New Sub" && + test_commit new && + + # retrigger the "die_path_inside_submodule" check with encoding + test_must_fail git submodule add ../new-sub "New Sub/nested2" + ) +' + +test_expect_success 'verify new submodule gitdir config' ' + git -C main config submodule."New Sub".gitdir >actual && + echo ".git/modules/New Sub" >expect && + test_cmp expect actual && + verify_submodule_gitdir_path main "New Sub" "modules/New Sub" +' + +test_expect_success 'manual add and verify legacy submodule gitdir config' ' + # the legacy module should not contain a gitdir config, because it + # was added before the extension was enabled. Add and test it. + test_must_fail git -C main config submodule.legacy.gitdir && + git -C main config submodule.legacy.gitdir .git/modules/legacy && + git -C main config submodule.legacy.gitdir >actual && + echo ".git/modules/legacy" >expect && + test_cmp expect actual && + verify_submodule_gitdir_path main "legacy" "modules/legacy" +' + +test_expect_success 'gitdir config path is relative for both absolute and relative urls' ' + test_when_finished "rm -rf relative-cfg-path-test" && + git init -b main relative-cfg-path-test && + ( + cd relative-cfg-path-test && + git config core.repositoryformatversion 1 && + git config extensions.submodulePathConfig true && + + # Test with absolute URL + git submodule add "$TRASH_DIRECTORY/new-sub" sub-abs && + git config submodule.sub-abs.gitdir >actual && + echo ".git/modules/sub-abs" >expect && + test_cmp expect actual && + + # Test with relative URL + git submodule add ../new-sub sub-rel && + git config submodule.sub-rel.gitdir >actual && + echo ".git/modules/sub-rel" >expect && + test_cmp expect actual + ) +' + +test_expect_success 'clone from repo with both legacy and new-style submodules' ' + git clone --recurse-submodules main cloned-non-extension && + ( + cd cloned-non-extension && + + test_path_is_dir .git/modules/legacy && + test_path_is_dir .git/modules/"New Sub" && + + test_must_fail git config submodule.legacy.gitdir && + test_must_fail git config submodule."New Sub".gitdir && + + git submodule status >list && + test_grep "$legacy_rev legacy" list && + test_grep "$new_rev New Sub" list + ) && + + git clone -c extensions.submodulePathConfig=true --recurse-submodules main cloned-extension && + ( + cd cloned-extension && + + test_path_is_dir .git/modules/legacy && + test_path_is_dir ".git/modules/New Sub" && + + git config submodule.legacy.gitdir && + git config submodule."New Sub".gitdir && + + git submodule status >list && + test_grep "$legacy_rev legacy" list && + test_grep "$new_rev New Sub" list + ) +' + +test_expect_success 'commit and push changes to encoded submodules' ' + git -C legacy-sub config receive.denyCurrentBranch updateInstead && + git -C new-sub config receive.denyCurrentBranch updateInstead && + git -C main config receive.denyCurrentBranch updateInstead && + ( + cd cloned-extension && + + git -C legacy switch --track -C main origin/main && + test_commit -C legacy second-commit && + git -C legacy push && + + git -C "New Sub" switch --track -C main origin/main && + test_commit -C "New Sub" second-commit && + git -C "New Sub" push && + + # Stage and commit submodule changes in superproject + git switch --track -C main origin/main && + git add legacy "New Sub" && + git commit -m "update submodules" && + + # push superproject commit to main repo + git push + ) && + + # update expected legacy & new submodule checksums + legacy_rev=$(git -C legacy-sub rev-parse HEAD) && + new_rev=$(git -C new-sub rev-parse HEAD) +' + +test_expect_success 'fetch mixed submodule changes and verify updates' ' + ( + cd main && + + # only update submodules because superproject was + # pushed into at the end of last test + git submodule update --init --recursive && + + test_path_is_dir .git/modules/legacy && + test_path_is_dir ".git/modules/New Sub" && + + # Verify both submodules are at the expected commits + git submodule status >list && + test_grep "$legacy_rev legacy" list && + test_grep "$new_rev New Sub" list + ) +' + +test_expect_success '`git init` respects init.defaultSubmodulePathConfig' ' + git config --global init.defaultSubmodulePathConfig true && + git init repo-init && + git -C repo-init config extensions.submodulePathConfig >actual && + echo true >expect && + test_cmp expect actual && + # create a submodule and check gitdir + ( + cd repo-init && + git init -b main sub && + test_commit -C sub sub-initial && + git submodule add ./sub sub && + git config submodule.sub.gitdir >actual && + echo ".git/modules/sub" >expect && + test_cmp expect actual + ) && + git config --global --unset init.defaultSubmodulePathConfig +' + +test_expect_success '`git init` does not set extension by default' ' + git init upstream && + test_commit -C upstream initial && + test_must_fail git -C upstream config extensions.submodulePathConfig && + # create a pair of submodules and check gitdir is not created + git init -b main sub && + test_commit -C sub sub-initial && + ( + cd upstream && + git submodule add ../sub sub1 && + test_path_is_dir .git/modules/sub1 && + test_must_fail git config submodule.sub1.gitdir && + git submodule add ../sub sub2 && + test_path_is_dir .git/modules/sub2 && + test_must_fail git config submodule.sub2.gitdir && + git commit -m "Add submodules" + ) +' + +test_expect_success '`git clone` does not set extension by default' ' + test_when_finished "rm -rf repo-clone-no-ext" && + git clone upstream repo-clone-no-ext && + ( + cd repo-clone-no-ext && + + test_must_fail git config extensions.submodulePathConfig && + test_path_is_missing .git/modules/sub1 && + test_path_is_missing .git/modules/sub2 && + + # create a submodule and check gitdir is not created + git submodule add ../sub sub3 && + test_must_fail git config submodule.sub3.gitdir + ) +' + +test_expect_success '`git clone --recurse-submodules` does not set extension by default' ' + test_when_finished "rm -rf repo-clone-no-ext" && + git clone --recurse-submodules upstream repo-clone-no-ext && + ( + cd repo-clone-no-ext && + + # verify that that submodules do not have gitdir set + test_must_fail git config extensions.submodulePathConfig && + test_path_is_dir .git/modules/sub1 && + test_must_fail git config submodule.sub1.gitdir && + test_path_is_dir .git/modules/sub2 && + test_must_fail git config submodule.sub2.gitdir && + + # create another submodule and check that gitdir is not created + git submodule add ../sub sub3 && + test_path_is_dir .git/modules/sub3 && + test_must_fail git config submodule.sub3.gitdir + ) + +' + +test_expect_success '`git clone` respects init.defaultSubmodulePathConfig' ' + test_when_finished "rm -rf repo-clone" && + git config --global init.defaultSubmodulePathConfig true && + git clone upstream repo-clone && + ( + cd repo-clone && + + # verify new repo extension is inherited from global config + git config extensions.submodulePathConfig >actual && + echo true >expect && + test_cmp expect actual && + + # new submodule has a gitdir config + git submodule add ../sub sub && + test_path_is_dir .git/modules/sub && + git config submodule.sub.gitdir >actual && + echo ".git/modules/sub" >expect && + test_cmp expect actual + ) && + git config --global --unset init.defaultSubmodulePathConfig +' + +test_expect_success '`git clone --recurse-submodules` respects init.defaultSubmodulePathConfig' ' + test_when_finished "rm -rf repo-clone-recursive" && + git config --global init.defaultSubmodulePathConfig true && + git clone --recurse-submodules upstream repo-clone-recursive && + ( + cd repo-clone-recursive && + + # verify new repo extension is inherited from global config + git config extensions.submodulePathConfig >actual && + echo true >expect && + test_cmp expect actual && + + # previous submodules should exist + git config submodule.sub1.gitdir && + git config submodule.sub2.gitdir && + test_path_is_dir .git/modules/sub1 && + test_path_is_dir .git/modules/sub2 && + + # create another submodule and check that gitdir is created + git submodule add ../sub new-sub && + test_path_is_dir .git/modules/new-sub && + git config submodule.new-sub.gitdir >actual && + echo ".git/modules/new-sub" >expect && + test_cmp expect actual + ) && + git config --global --unset init.defaultSubmodulePathConfig +' + +test_expect_success 'submodule--helper migrates legacy modules' ' + ( + cd upstream && + + # previous submodules exist and were not migrated yet + test_must_fail git config submodule.sub1.gitdir && + test_must_fail git config submodule.sub2.gitdir && + test_path_is_dir .git/modules/sub1 && + test_path_is_dir .git/modules/sub2 && + + # run migration + git submodule--helper migrate-gitdir-configs && + + # test that migration worked + git config submodule.sub1.gitdir >actual && + echo ".git/modules/sub1" >expect && + test_cmp expect actual && + git config submodule.sub2.gitdir >actual && + echo ".git/modules/sub2" >expect && + test_cmp expect actual && + + # repository extension is enabled after migration + git config extensions.submodulePathConfig >actual && + echo "true" >expect && + test_cmp expect actual + ) +' + +test_expect_success '`git clone --recurse-submodules` works after migration' ' + test_when_finished "rm -rf repo-clone-recursive" && + + # test with extension disabled after the upstream repo was migrated + git clone --recurse-submodules upstream repo-clone-recursive && + ( + cd repo-clone-recursive && + + # init.defaultSubmodulePathConfig was disabled before clone, so + # the repo extension config should also be off, the migration ignored + test_must_fail git config extensions.submodulePathConfig && + + # modules should look like there was no migration done + test_must_fail git config submodule.sub1.gitdir && + test_must_fail git config submodule.sub2.gitdir && + test_path_is_dir .git/modules/sub1 && + test_path_is_dir .git/modules/sub2 + ) && + rm -rf repo-clone-recursive && + + # enable the extension, then retry the clone + git config --global init.defaultSubmodulePathConfig true && + git clone --recurse-submodules upstream repo-clone-recursive && + ( + cd repo-clone-recursive && + + # repository extension is enabled + git config extensions.submodulePathConfig >actual && + echo "true" >expect && + test_cmp expect actual && + + # gitdir configs exist for submodules + git config submodule.sub1.gitdir && + git config submodule.sub2.gitdir && + test_path_is_dir .git/modules/sub1 && + test_path_is_dir .git/modules/sub2 + ) && + git config --global --unset init.defaultSubmodulePathConfig +' + +test_expect_success 'setup submodules with nested git dirs' ' + git init nested && + test_commit -C nested nested && + ( + cd nested && + cat >.gitmodules <<-EOF && + [submodule "hippo"] + url = . + path = thing1 + [submodule "hippo/hooks"] + url = . + path = thing2 + EOF + git clone . thing1 && + git clone . thing2 && + git add .gitmodules thing1 thing2 && + test_tick && + git commit -m nested + ) +' + +test_expect_success 'git dirs of encoded sibling submodules must not be nested' ' + git clone -c extensions.submodulePathConfig=true --recurse-submodules nested clone_nested && + + verify_submodule_gitdir_path clone_nested hippo modules/hippo && + git -C clone_nested config submodule.hippo.gitdir >actual && + test_grep "\.git/modules/hippo$" actual && + + verify_submodule_gitdir_path clone_nested hippo/hooks modules/hippo%2fhooks && + git -C clone_nested config submodule.hippo/hooks.gitdir >actual && + test_grep "\.git/modules/hippo%2fhooks$" actual +' + +test_expect_success 'submodule git dir nesting detection must work with parallel cloning' ' + git clone -c extensions.submodulePathConfig=true --recurse-submodules --jobs=2 nested clone_parallel && + + verify_submodule_gitdir_path clone_parallel hippo modules/hippo && + git -C clone_nested config submodule.hippo.gitdir >actual && + test_grep "\.git/modules/hippo$" actual && + + verify_submodule_gitdir_path clone_parallel hippo/hooks modules/hippo%2fhooks && + git -C clone_nested config submodule.hippo/hooks.gitdir >actual && + test_grep "\.git/modules/hippo%2fhooks$" actual +' + +test_expect_success 'disabling extensions.submodulePathConfig prevents nested submodules' ' + ( + cd clone_nested && + # disable extension and verify failure + git config --replace-all extensions.submodulePathConfig false && + test_must_fail git submodule add ./thing2 hippo/foobar && + # re-enable extension and verify it works + git config --replace-all extensions.submodulePathConfig true && + git submodule add ./thing2 hippo/foobar + ) +' + +test_expect_success CASE_INSENSITIVE_FS 'verify case-folding conflicts are correctly encoded' ' + git clone -c extensions.submodulePathConfig=true main cloned-folding && + ( + cd cloned-folding && + + # conflict: the "folding" gitdir will already be taken + git submodule add ../new-sub "folding" && + test_commit lowercase && + git submodule add ../new-sub "FoldinG" && + test_commit uppercase && + + # conflict: the "foo" gitdir will already be taken + git submodule add ../new-sub "FOO" && + test_commit uppercase-foo && + git submodule add ../new-sub "foo" && + test_commit lowercase-foo && + + # create a multi conflict between foobar, fooBar and foo%42ar + # the "foo" gitdir will already be taken + git submodule add ../new-sub "foobar" && + test_commit lowercase-foobar && + git submodule add ../new-sub "foo%42ar" && + test_commit encoded-foo%42ar && + git submodule add ../new-sub "fooBar" && + test_commit mixed-fooBar + ) && + verify_submodule_gitdir_path cloned-folding "folding" "modules/folding" && + verify_submodule_gitdir_path cloned-folding "FoldinG" "modules/%46oldin%47" && + verify_submodule_gitdir_path cloned-folding "FOO" "modules/FOO" && + verify_submodule_gitdir_path cloned-folding "foo" "modules/foo0" && + verify_submodule_gitdir_path cloned-folding "foobar" "modules/foobar" && + verify_submodule_gitdir_path cloned-folding "foo%42ar" "modules/foo%42ar" && + verify_submodule_gitdir_path cloned-folding "fooBar" "modules/fooBar0" +' + +test_expect_success CASE_INSENSITIVE_FS 'verify hashing conflict resolution as a last resort' ' + git clone -c extensions.submodulePathConfig=true main cloned-hash && + ( + cd cloned-hash && + + # conflict: add all submodule conflicting variants until we reach the + # final hashing conflict resolution for submodule "foo" + git submodule add ../new-sub "foo" && + git submodule add ../new-sub "foo0" && + git submodule add ../new-sub "foo1" && + git submodule add ../new-sub "foo2" && + git submodule add ../new-sub "foo3" && + git submodule add ../new-sub "foo4" && + git submodule add ../new-sub "foo5" && + git submodule add ../new-sub "foo6" && + git submodule add ../new-sub "foo7" && + git submodule add ../new-sub "foo8" && + git submodule add ../new-sub "foo9" && + git submodule add ../new-sub "%46oo" && + git submodule add ../new-sub "%46oo0" && + git submodule add ../new-sub "%46oo1" && + git submodule add ../new-sub "%46oo2" && + git submodule add ../new-sub "%46oo3" && + git submodule add ../new-sub "%46oo4" && + git submodule add ../new-sub "%46oo5" && + git submodule add ../new-sub "%46oo6" && + git submodule add ../new-sub "%46oo7" && + git submodule add ../new-sub "%46oo8" && + git submodule add ../new-sub "%46oo9" && + test_commit add-foo-variants && + git submodule add ../new-sub "Foo" && + test_commit add-uppercase-foo + ) && + verify_submodule_gitdir_path cloned-hash "foo" "modules/foo" && + verify_submodule_gitdir_path cloned-hash "foo0" "modules/foo0" && + verify_submodule_gitdir_path cloned-hash "foo1" "modules/foo1" && + verify_submodule_gitdir_path cloned-hash "foo2" "modules/foo2" && + verify_submodule_gitdir_path cloned-hash "foo3" "modules/foo3" && + verify_submodule_gitdir_path cloned-hash "foo4" "modules/foo4" && + verify_submodule_gitdir_path cloned-hash "foo5" "modules/foo5" && + verify_submodule_gitdir_path cloned-hash "foo6" "modules/foo6" && + verify_submodule_gitdir_path cloned-hash "foo7" "modules/foo7" && + verify_submodule_gitdir_path cloned-hash "foo8" "modules/foo8" && + verify_submodule_gitdir_path cloned-hash "foo9" "modules/foo9" && + verify_submodule_gitdir_path cloned-hash "%46oo" "modules/%46oo" && + verify_submodule_gitdir_path cloned-hash "%46oo0" "modules/%46oo0" && + verify_submodule_gitdir_path cloned-hash "%46oo1" "modules/%46oo1" && + verify_submodule_gitdir_path cloned-hash "%46oo2" "modules/%46oo2" && + verify_submodule_gitdir_path cloned-hash "%46oo3" "modules/%46oo3" && + verify_submodule_gitdir_path cloned-hash "%46oo4" "modules/%46oo4" && + verify_submodule_gitdir_path cloned-hash "%46oo5" "modules/%46oo5" && + verify_submodule_gitdir_path cloned-hash "%46oo6" "modules/%46oo6" && + verify_submodule_gitdir_path cloned-hash "%46oo7" "modules/%46oo7" && + verify_submodule_gitdir_path cloned-hash "%46oo8" "modules/%46oo8" && + verify_submodule_gitdir_path cloned-hash "%46oo9" "modules/%46oo9" && + hash=$(printf "Foo" | git hash-object --stdin) && + verify_submodule_gitdir_path cloned-hash "Foo" "modules/${hash}" +' + +test_expect_success 'submodule gitdir conflicts with previously encoded name (local config)' ' + git init -b main super_with_encoded && + ( + cd super_with_encoded && + + git config core.repositoryformatversion 1 && + git config extensions.submodulePathConfig true && + + # Add a submodule with a nested path + git submodule add --name "nested/sub" ../sub nested/sub && + test_commit add-encoded-gitdir && + + verify_submodule_gitdir_path . "nested/sub" "modules/nested%2fsub" && + test_path_is_dir ".git/modules/nested%2fsub" + ) && + + # create a submodule that will conflict with the encoded gitdir name: + # the existing gitdir is ".git/modules/nested%2fsub", which is used + # by "nested/sub", so the new submod will get another (non-conflicting) + # name: "nested%252fsub". + ( + cd super_with_encoded && + git submodule add ../sub "nested%2fsub" && + verify_submodule_gitdir_path . "nested%2fsub" "modules/nested%252fsub" && + test_path_is_dir ".git/modules/nested%252fsub" + ) +' + +test_done diff --git a/t/t9902-completion.sh b/t/t9902-completion.sh index 964e1f1569..ffb9c8b522 100755 --- a/t/t9902-completion.sh +++ b/t/t9902-completion.sh @@ -3053,6 +3053,7 @@ test_expect_success 'git config set - variable name - __git_compute_second_level submodule.sub.fetchRecurseSubmodules Z submodule.sub.ignore Z submodule.sub.active Z + submodule.sub.gitdir Z EOF ' diff --git a/url.c b/url.c index 282b12495a..3ca5987e90 100644 --- a/url.c +++ b/url.c @@ -3,6 +3,19 @@ #include "strbuf.h" #include "url.h" +int is_rfc3986_unreserved(char ch) +{ + return isalnum(ch) || + ch == '-' || ch == '_' || ch == '.' || ch == '~'; +} + +int is_casefolding_rfc3986_unreserved(char c) +{ + return (c >= 'a' && c <= 'z') || + (c >= '0' && c <= '9') || + c == '-' || c == '.' || c == '_' || c == '~'; +} + int is_urlschemechar(int first_flag, int ch) { /* diff --git a/url.h b/url.h index 2a27c34277..cd9140e994 100644 --- a/url.h +++ b/url.h @@ -21,4 +21,18 @@ char *url_decode_parameter_value(const char **query); void end_url_with_slash(struct strbuf *buf, const char *url); void str_end_url_with_slash(const char *url, char **dest); +/* + * The set of unreserved characters as per STD66 (RFC3986) is + * '[A-Za-z0-9-._~]'. These characters are safe to appear in URI + * components without percent-encoding. + */ +int is_rfc3986_unreserved(char ch); + +/* + * This is a variant of is_rfc3986_unreserved() that treats uppercase + * letters as "reserved". This forces them to be percent-encoded, allowing + * 'Foo' (%46oo) and 'foo' (foo) to be distinct on case-folding filesystems. + */ +int is_casefolding_rfc3986_unreserved(char c); + #endif /* URL_H */