config: add conditional include

Sometimes a set of repositories want to share configuration settings among themselves that are distinct from other such sets of repositories. A user may work on two projects, each of which have multiple repositories, and use one user.email for one project while using another for the other. Setting $GIT_DIR/.config works, but if the penalty of forgetting to update $GIT_DIR/.config is high (especially when you end up cloning often), it may not be the best way to go. Having the settings in ~/.gitconfig, which would work for just one set of repositories, would not well in such a situation. Having separate ${HOME}s may add more problems than it solves. Extend the include.path mechanism that lets a config file include another config file, so that the inclusion can be done only when some conditions hold. Then ~/.gitconfig can say "include config-project-A only when working on project-A" for each project A the user works on. In this patch, the only supported grouping is based on $GIT_DIR (in absolute path), so you would need to group repositories by directory, or something like that to take advantage of it. We already have include.path for unconditional includes. This patch goes with includeIf.<condition>.path to make it clearer that a condition is required. The new config has the same backward compatibility approach as include.path: older git versions that don't understand includeIf will simply ignore them. Signed-off-by: Nguyễn Thái Ngọc Duy <pclouds@gmail.com> Signed-off-by: Junio C Hamano <gitster@pobox.com>

Nguyễn Thái Ngọc Duy committed Mar 1, 2017 at 18:26 UTC 3efd0bedc6625a6b194c1f6e5f1b7aa7d8b7e6bb
3 files changed +212 -1
Documentation/config.txt
+63
@@ -90,6 +90,58 @@ found at the location of the include directive. If the value of the
90 be relative to the configuration file in which the include directive
91 was found. See below for examples.
92
93 +Conditional includes
94 +~~~~~~~~~~~~~~~~~~~~
95 +
96 +You can include a config file from another conditionally by setting a
97 +`includeIf.<condition>.path` variable to the name of the file to be
98 +included. The variable's value is treated the same way as
99 +`include.path`. `includeIf.<condition>.path` can be given multiple times.
100 +
101 +The condition starts with a keyword followed by a colon and some data
102 +whose format and meaning depends on the keyword. Supported keywords
103 +are:
104 +
105 +`gitdir`::
106 +
107 + The data that follows the keyword `gitdir:` is used as a glob
108 + pattern. If the location of the .git directory matches the
109 + pattern, the include condition is met.
110 ++
111 +The .git location may be auto-discovered, or come from `$GIT_DIR`
112 +environment variable. If the repository is auto discovered via a .git
113 +file (e.g. from submodules, or a linked worktree), the .git location
114 +would be the final location where the .git directory is, not where the
115 +.git file is.
116 ++
117 +The pattern can contain standard globbing wildcards and two additional
118 +ones, `**/` and `/**`, that can match multiple path components. Please
119 +refer to linkgit:gitignore[5] for details. For convenience:
120 +
121 + * If the pattern starts with `~/`, `~` will be substituted with the
122 + content of the environment variable `HOME`.
123 +
124 + * If the pattern starts with `./`, it is replaced with the directory
125 + containing the current config file.
126 +
127 + * If the pattern does not start with either `~/`, `./` or `/`, `**/`
128 + will be automatically prepended. For example, the pattern `foo/bar`
129 + becomes `**/foo/bar` and would match `/any/path/to/foo/bar`.
130 +
131 + * If the pattern ends with `/`, `**` will be automatically added. For
132 + example, the pattern `foo/` becomes `foo/**`. In other words, it
133 + matches "foo" and everything inside, recursively.
134 +
135 +`gitdir/i`::
136 + This is the same as `gitdir` except that matching is done
137 + case-insensitively (e.g. on case-insensitive file sytems)
138 +
139 +A few more notes on matching via `gitdir` and `gitdir/i`:
140 +
141 + * Symlinks in `$GIT_DIR` are not resolved before matching.
142 +
143 + * Note that "../" is not special and will match literally, which is
144 + unlikely what you want.
145
146 Example
147 ~~~~~~~
@@ -118,6 +170,17 @@ Example
170 path = foo ; expand "foo" relative to the current file
171 path = ~/foo ; expand "foo" in your `$HOME` directory
172
173 + ; include if $GIT_DIR is /path/to/foo/.git
174 + [includeIf "gitdir:/path/to/foo/.git"]
175 + path = /path/to/foo.inc
176 +
177 + ; include for all repositories inside /path/to/group
178 + [includeIf "gitdir:/path/to/group/"]
179 + path = /path/to/foo.inc
180 +
181 + ; include for all repositories inside $HOME/to/group
182 + [includeIf "gitdir:~/to/group/"]
183 + path = /path/to/foo.inc
184
185 Values
186 ~~~~~~
config.c
+92
@@ -13,6 +13,7 @@
13 #include "hashmap.h"
14 #include "string-list.h"
15 #include "utf8.h"
16 +#include "dir.h"
17
18 struct config_source {
19 struct config_source *prev;
@@ -170,9 +171,94 @@ static int handle_path_include(const char *path, struct config_include_data *inc
171 return ret;
172 }
173
174 +static int prepare_include_condition_pattern(struct strbuf *pat)
175 +{
176 + struct strbuf path = STRBUF_INIT;
177 + char *expanded;
178 + int prefix = 0;
179 +
180 + expanded = expand_user_path(pat->buf);
181 + if (expanded) {
182 + strbuf_reset(pat);
183 + strbuf_addstr(pat, expanded);
184 + free(expanded);
185 + }
186 +
187 + if (pat->buf[0] == '.' && is_dir_sep(pat->buf[1])) {
188 + const char *slash;
189 +
190 + if (!cf || !cf->path)
191 + return error(_("relative config include "
192 + "conditionals must come from files"));
193 +
194 + strbuf_add_absolute_path(&path, cf->path);
195 + slash = find_last_dir_sep(path.buf);
196 + if (!slash)
197 + die("BUG: how is this possible?");
198 + strbuf_splice(pat, 0, 1, path.buf, slash - path.buf);
199 + prefix = slash - path.buf + 1 /* slash */;
200 + } else if (!is_absolute_path(pat->buf))
201 + strbuf_insert(pat, 0, "**/", 3);
202 +
203 + if (pat->len && is_dir_sep(pat->buf[pat->len - 1]))
204 + strbuf_addstr(pat, "**");
205 +
206 + strbuf_release(&path);
207 + return prefix;
208 +}
209 +
210 +static int include_by_gitdir(const char *cond, size_t cond_len, int icase)
211 +{
212 + struct strbuf text = STRBUF_INIT;
213 + struct strbuf pattern = STRBUF_INIT;
214 + int ret = 0, prefix;
215 +
216 + strbuf_add_absolute_path(&text, get_git_dir());
217 + strbuf_add(&pattern, cond, cond_len);
218 + prefix = prepare_include_condition_pattern(&pattern);
219 +
220 + if (prefix < 0)
221 + goto done;
222 +
223 + if (prefix > 0) {
224 + /*
225 + * perform literal matching on the prefix part so that
226 + * any wildcard character in it can't create side effects.
227 + */
228 + if (text.len < prefix)
229 + goto done;
230 + if (!icase && strncmp(pattern.buf, text.buf, prefix))
231 + goto done;
232 + if (icase && strncasecmp(pattern.buf, text.buf, prefix))
233 + goto done;
234 + }
235 +
236 + ret = !wildmatch(pattern.buf + prefix, text.buf + prefix,
237 + icase ? WM_CASEFOLD : 0, NULL);
238 +
239 +done:
240 + strbuf_release(&pattern);
241 + strbuf_release(&text);
242 + return ret;
243 +}
244 +
245 +static int include_condition_is_true(const char *cond, size_t cond_len)
246 +{
247 +
248 + if (skip_prefix_mem(cond, cond_len, "gitdir:", &cond, &cond_len))
249 + return include_by_gitdir(cond, cond_len, 0);
250 + else if (skip_prefix_mem(cond, cond_len, "gitdir/i:", &cond, &cond_len))
251 + return include_by_gitdir(cond, cond_len, 1);
252 +
253 + /* unknown conditionals are always false */
254 + return 0;
255 +}
256 +
257 int git_config_include(const char *var, const char *value, void *data)
258 {
259 struct config_include_data *inc = data;
260 + const char *cond, *key;
261 + int cond_len;
262 int ret;
263
264 /*
@@ -185,6 +271,12 @@ int git_config_include(const char *var, const char *value, void *data)
271
272 if (!strcmp(var, "include.path"))
273 ret = handle_path_include(value, inc);
274 +
275 + if (!parse_config_key(var, "includeif", &cond, &cond_len, &key) &&
276 + (cond && include_condition_is_true(cond, cond_len)) &&
277 + !strcmp(key, "path"))
278 + ret = handle_path_include(value, inc);
279 +
280 return ret;
281 }
282
t/t1305-config-include.sh
+57 -1
@@ -102,7 +102,7 @@ test_expect_success 'config modification does not affect includes' '
102
103 test_expect_success 'missing include files are ignored' '
104 cat >.gitconfig <<-\EOF &&
105 - [include]path = foo
105 + [include]path = non-existent
106 [test]value = yes
107 EOF
108 echo yes >expect &&
@@ -152,6 +152,62 @@ test_expect_success 'relative includes from stdin line fail' '
152 test_must_fail git config --file - test.one
153 '
154
155 +test_expect_success 'conditional include, both unanchored' '
156 + git init foo &&
157 + (
158 + cd foo &&
159 + echo "[includeIf \"gitdir:foo/\"]path=bar" >>.git/config &&
160 + echo "[test]one=1" >.git/bar &&
161 + echo 1 >expect &&
162 + git config test.one >actual &&
163 + test_cmp expect actual
164 + )
165 +'
166 +
167 +test_expect_success 'conditional include, $HOME expansion' '
168 + (
169 + cd foo &&
170 + echo "[includeIf \"gitdir:~/foo/\"]path=bar2" >>.git/config &&
171 + echo "[test]two=2" >.git/bar2 &&
172 + echo 2 >expect &&
173 + git config test.two >actual &&
174 + test_cmp expect actual
175 + )
176 +'
177 +
178 +test_expect_success 'conditional include, full pattern' '
179 + (
180 + cd foo &&
181 + echo "[includeIf \"gitdir:**/foo/**\"]path=bar3" >>.git/config &&
182 + echo "[test]three=3" >.git/bar3 &&
183 + echo 3 >expect &&
184 + git config test.three >actual &&
185 + test_cmp expect actual
186 + )
187 +'
188 +
189 +test_expect_success 'conditional include, relative path' '
190 + echo "[includeIf \"gitdir:./foo/.git\"]path=bar4" >>.gitconfig &&
191 + echo "[test]four=4" >bar4 &&
192 + (
193 + cd foo &&
194 + echo 4 >expect &&
195 + git config test.four >actual &&
196 + test_cmp expect actual
197 + )
198 +'
199 +
200 +test_expect_success 'conditional include, both unanchored, icase' '
201 + (
202 + cd foo &&
203 + echo "[includeIf \"gitdir/i:FOO/\"]path=bar5" >>.git/config &&
204 + echo "[test]five=5" >.git/bar5 &&
205 + echo 5 >expect &&
206 + git config test.five >actual &&
207 + test_cmp expect actual
208 + )
209 +'
210 +
211 test_expect_success 'include cycles are detected' '
212 cat >.gitconfig <<-\EOF &&
213 [test]value = gitconfig