From c4dab0609613bc5d43bce705dca2f057674a5d5b Mon Sep 17 00:00:00 2001 From: Derrick Stolee Date: Thu, 8 Jan 2026 17:02:24 -0500 Subject: [PATCH 01/11] config-batch: basic boilerplate of new builtin Later changes will document, implement, and test this new builtin. For now, this serves as the latest example of the minimum boilerplate to introduce a new builtin. Recently, we updated the comment in builtin.h about how to create a new builtin, but failed to mention the required change to meson.build files for some CI builds to pass. Fix that oversight. Signed-off-by: Derrick Stolee --- .gitignore | 1 + Documentation/git-config-batch.adoc | 24 +++++++++++++++++++++++ Documentation/meson.build | 1 + Makefile | 1 + builtin.h | 7 +++++++ builtin/config-batch.c | 30 +++++++++++++++++++++++++++++ command-list.txt | 1 + git.c | 1 + meson.build | 1 + t/meson.build | 1 + t/t1312-config-batch.sh | 12 ++++++++++++ 11 files changed, 80 insertions(+) create mode 100644 Documentation/git-config-batch.adoc create mode 100644 builtin/config-batch.c create mode 100755 t/t1312-config-batch.sh diff --git a/.gitignore b/.gitignore index 78a45cb5bec991..42640b5e249c8f 100644 --- a/.gitignore +++ b/.gitignore @@ -44,6 +44,7 @@ /git-commit-graph /git-commit-tree /git-config +/git-config-batch /git-count-objects /git-credential /git-credential-cache diff --git a/Documentation/git-config-batch.adoc b/Documentation/git-config-batch.adoc new file mode 100644 index 00000000000000..dfa0bd83e25f61 --- /dev/null +++ b/Documentation/git-config-batch.adoc @@ -0,0 +1,24 @@ +git-config-batch(1) +=================== + +NAME +---- +git-config-batch - Get and set options using machine-parseable interface + + +SYNOPSIS +-------- +[verse] +'git config-batch' + +DESCRIPTION +----------- +TODO + +SEE ALSO +-------- +linkgit:git-config[1] + +GIT +--- +Part of the linkgit:git[1] suite diff --git a/Documentation/meson.build b/Documentation/meson.build index f02dbc20cbcb86..f5ad1179213682 100644 --- a/Documentation/meson.build +++ b/Documentation/meson.build @@ -29,6 +29,7 @@ manpages = { 'git-commit-tree.adoc' : 1, 'git-commit.adoc' : 1, 'git-config.adoc' : 1, + 'git-config-batch.adoc' : 1, 'git-count-objects.adoc' : 1, 'git-credential-cache--daemon.adoc' : 1, 'git-credential-cache.adoc' : 1, diff --git a/Makefile b/Makefile index 8aa489f3b6812f..aa3868e5134119 100644 --- a/Makefile +++ b/Makefile @@ -1390,6 +1390,7 @@ BUILTIN_OBJS += builtin/commit-graph.o BUILTIN_OBJS += builtin/commit-tree.o BUILTIN_OBJS += builtin/commit.o BUILTIN_OBJS += builtin/config.o +BUILTIN_OBJS += builtin/config-batch.o BUILTIN_OBJS += builtin/count-objects.o BUILTIN_OBJS += builtin/credential-cache--daemon.o BUILTIN_OBJS += builtin/credential-cache.o diff --git a/builtin.h b/builtin.h index e5e16ecaa6c9d7..5f5a19635ee57c 100644 --- a/builtin.h +++ b/builtin.h @@ -68,12 +68,18 @@ * * . Add `builtin/foo.o` to `BUILTIN_OBJS` in `Makefile`. * + * . Add 'builtin/foo.c' to the 'builtin_sources' array in 'meson.build'. + * * Additionally, if `foo` is a new command, there are 4 more things to do: * * . Add tests to `t/` directory. * + * . Add the test script to 'integration_tests' in 't/meson.build'. + * * . Write documentation in `Documentation/git-foo.adoc`. * + * . Add 'git-foo.adoc' to the manpages list in 'Documentation/meson.build'. + * * . Add an entry for `git-foo` to `command-list.txt`. * * . Add an entry for `/git-foo` to `.gitignore`. @@ -167,6 +173,7 @@ int cmd_commit(int argc, const char **argv, const char *prefix, struct repositor int cmd_commit_graph(int argc, const char **argv, const char *prefix, struct repository *repo); int cmd_commit_tree(int argc, const char **argv, const char *prefix, struct repository *repo); int cmd_config(int argc, const char **argv, const char *prefix, struct repository *repo); +int cmd_config_batch(int argc, const char **argv, const char *prefix, struct repository *repo); int cmd_count_objects(int argc, const char **argv, const char *prefix, struct repository *repo); int cmd_credential(int argc, const char **argv, const char *prefix, struct repository *repo); int cmd_credential_cache(int argc, const char **argv, const char *prefix, struct repository *repo); diff --git a/builtin/config-batch.c b/builtin/config-batch.c new file mode 100644 index 00000000000000..ea4f408ecb45d6 --- /dev/null +++ b/builtin/config-batch.c @@ -0,0 +1,30 @@ +#define USE_THE_REPOSITORY_VARIABLE +#include "builtin.h" +#include "config.h" +#include "environment.h" +#include "parse-options.h" + +static const char *const builtin_config_batch_usage[] = { + N_("git config-batch "), + NULL +}; + +int cmd_config_batch(int argc, + const char **argv, + const char *prefix, + struct repository *repo) +{ + struct option options[] = { + OPT_END(), + }; + + show_usage_with_options_if_asked(argc, argv, + builtin_config_batch_usage, options); + + argc = parse_options(argc, argv, prefix, options, builtin_config_batch_usage, + 0); + + repo_config(repo, git_default_config, NULL); + + return 0; +} diff --git a/command-list.txt b/command-list.txt index accd3d0c4b5524..57c7c7458d9b26 100644 --- a/command-list.txt +++ b/command-list.txt @@ -83,6 +83,7 @@ git-commit mainporcelain history git-commit-graph plumbingmanipulators git-commit-tree plumbingmanipulators git-config ancillarymanipulators complete +git-config-batch plumbinginterrogators git-count-objects ancillaryinterrogators git-credential purehelpers git-credential-cache purehelpers diff --git a/git.c b/git.c index c5fad56813f437..6b55a867dd5809 100644 --- a/git.c +++ b/git.c @@ -557,6 +557,7 @@ static struct cmd_struct commands[] = { { "commit-graph", cmd_commit_graph, RUN_SETUP }, { "commit-tree", cmd_commit_tree, RUN_SETUP }, { "config", cmd_config, RUN_SETUP_GENTLY | DELAY_PAGER_CONFIG }, + { "config-batch", cmd_config_batch, RUN_SETUP_GENTLY }, { "count-objects", cmd_count_objects, RUN_SETUP }, { "credential", cmd_credential, RUN_SETUP_GENTLY | NO_PARSEOPT }, { "credential-cache", cmd_credential_cache }, diff --git a/meson.build b/meson.build index dd52efd1c87574..040bc32c2dc3eb 100644 --- a/meson.build +++ b/meson.build @@ -582,6 +582,7 @@ builtin_sources = [ 'builtin/commit-tree.c', 'builtin/commit.c', 'builtin/config.c', + 'builtin/config-batch.c', 'builtin/count-objects.c', 'builtin/credential-cache--daemon.c', 'builtin/credential-cache.c', diff --git a/t/meson.build b/t/meson.build index 459c52a48972e4..0e9f1826f8b948 100644 --- a/t/meson.build +++ b/t/meson.build @@ -186,6 +186,7 @@ integration_tests = [ 't1309-early-config.sh', 't1310-config-default.sh', 't1311-config-optional.sh', + 't1312-config-batch.sh', 't1350-config-hooks-path.sh', 't1400-update-ref.sh', 't1401-symbolic-ref.sh', diff --git a/t/t1312-config-batch.sh b/t/t1312-config-batch.sh new file mode 100755 index 00000000000000..f59ba4a0f3f1dc --- /dev/null +++ b/t/t1312-config-batch.sh @@ -0,0 +1,12 @@ +#!/bin/sh + +test_description='Test git config-batch' + +. ./test-lib.sh + +test_expect_success 'help text' ' + test_must_fail git config-batch -h >out && + grep usage out +' + +test_done From ecd26a0f1fad5615aea07a388e34f02e9f33b870 Mon Sep 17 00:00:00 2001 From: Derrick Stolee Date: Sat, 17 Jan 2026 13:16:55 -0500 Subject: [PATCH 02/11] config-batch: create parse loop and unknown command As we build new features in the config-batch command, we define the plaintext protocol with line-by-line output and responses. To think to the future, we make sure that the protocol has a clear way to respond to an unknown command or an unknown version of that command. As some commands will allow the final argument to contain spaces or even be able to parse "\ " as a non-split token, we only provide the remaining line as data. Signed-off-by: Derrick Stolee --- Documentation/git-config-batch.adoc | 23 ++++- builtin/config-batch.c | 133 +++++++++++++++++++++++++++- t/t1312-config-batch.sh | 19 +++- 3 files changed, 170 insertions(+), 5 deletions(-) diff --git a/Documentation/git-config-batch.adoc b/Documentation/git-config-batch.adoc index dfa0bd83e25f61..9ca04b0c1eafd2 100644 --- a/Documentation/git-config-batch.adoc +++ b/Documentation/git-config-batch.adoc @@ -13,7 +13,28 @@ SYNOPSIS DESCRIPTION ----------- -TODO +Tools frequently need to change their behavior based on values stored in +Git's configuration files. These files may have complicated conditions +for including extra files, so it is difficult to produce an independent +parser. To avoid executing multiple processes to discover or modify +multiple configuration values, the `git config-batch` command allows a +single process to handle multiple requests using a machine-parseable +interface across `stdin` and `stdout`. + +PROTOCOL +-------- +By default, the protocol uses line feeds (`LF`) to signal the end of a +command over `stdin` or a response over `stdout`. + +The protocol will be extended in the future, and consumers should be +resilient to older Git versions not understanding the latest command +set. Thus, if the Git version includes the `git config-batch` builtin +but doesn't understand an input command, it will return a single line +response: + +``` +unknown_command LF +``` SEE ALSO -------- diff --git a/builtin/config-batch.c b/builtin/config-batch.c index ea4f408ecb45d6..dffedb8ca24199 100644 --- a/builtin/config-batch.c +++ b/builtin/config-batch.c @@ -3,17 +3,144 @@ #include "config.h" #include "environment.h" #include "parse-options.h" +#include "strbuf.h" +#include "string-list.h" static const char *const builtin_config_batch_usage[] = { N_("git config-batch "), NULL }; +#define UNKNOWN_COMMAND "unknown_command" + +static int emit_response(const char *response, ...) +{ + va_list params; + const char *token; + + printf("%s", response); + + va_start(params, response); + while ((token = va_arg(params, const char *))) + printf(" %s", token); + va_end(params); + + printf("\n"); + fflush(stdout); + return 0; +} + +/** + * A function pointer type for defining a command. The function is + * responsible for handling different versions of the command name. + * + * Provides the remaining 'data' for the command, to be parsed by + * the function as needed according to its parsing rules. + * + * These functions should only return a negative value if they result + * in such a catastrophic failure that the process should end. + * + * Return 0 on success. + */ +typedef int (*command_fn)(struct repository *repo, + char *data, size_t data_len); + +static int unknown_command(struct repository *repo UNUSED, + char *data UNUSED, size_t data_len UNUSED) +{ + return emit_response(UNKNOWN_COMMAND, NULL); +} + +struct command { + const char *name; + command_fn fn; + int version; +}; + +static struct command commands[] = { + /* unknown_command must be last. */ + { + .name = "", + .fn = unknown_command, + }, +}; + +#define COMMAND_COUNT ((size_t)(sizeof(commands) / sizeof(*commands))) + +/** + * Process a single line from stdin and process the command. + * + * Returns 0 on successful processing of command, including the + * unknown_command output. + * + * Returns 1 on natural exit due to exist signal of empty line. + * + * Returns negative value on other catastrophic error. + */ +static int process_command(struct repository *repo) +{ + static struct strbuf line = STRBUF_INIT; + struct string_list tokens = STRING_LIST_INIT_NODUP; + const char *command; + int version; + char *data = NULL; + size_t data_len = 0; + int res = 0; + + strbuf_getline(&line, stdin); + + if (!line.len) + return 1; + + /* Parse out the first two tokens, command and version. */ + string_list_split_in_place(&tokens, line.buf, " ", 2); + + if (tokens.nr < 2) { + res = error(_("expected at least 2 tokens, got %"PRIu32), + (uint32_t)tokens.nr); + goto cleanup; + } + + command = tokens.items[0].string; + + if (!git_parse_int(tokens.items[1].string, &version)) { + res = error(_("unable to parse '%s' to integer"), + tokens.items[1].string); + goto cleanup; + } + + if (tokens.nr >= 3) { + data = tokens.items[2].string; + data_len = strlen(tokens.items[2].string); + } + + for (size_t i = 0; i < COMMAND_COUNT; i++) { + /* + * Run the ith command if we have hit the unknown + * command or if the name and version match. + */ + if (!commands[i].name[0] || + (!strcmp(command, commands[i].name) && + commands[i].version == version)) { + res = commands[i].fn(repo, data, data_len); + goto cleanup; + } + } + + BUG(_("scanned to end of command list, including 'unknown_command'")); + +cleanup: + strbuf_reset(&line); + string_list_clear(&tokens, 0); + return res; +} + int cmd_config_batch(int argc, const char **argv, const char *prefix, struct repository *repo) { + int res = 0; struct option options[] = { OPT_END(), }; @@ -26,5 +153,9 @@ int cmd_config_batch(int argc, repo_config(repo, git_default_config, NULL); - return 0; + while (!(res = process_command(repo))); + + if (res == 1) + return 0; + die(_("an unrecoverable error occurred during command execution")); } diff --git a/t/t1312-config-batch.sh b/t/t1312-config-batch.sh index f59ba4a0f3f1dc..f60ef35e38d7c1 100755 --- a/t/t1312-config-batch.sh +++ b/t/t1312-config-batch.sh @@ -4,9 +4,22 @@ test_description='Test git config-batch' . ./test-lib.sh -test_expect_success 'help text' ' - test_must_fail git config-batch -h >out && - grep usage out +test_expect_success 'no commands' ' + echo | git config-batch >out && + test_must_be_empty out +' + +test_expect_success 'unknown_command' ' + echo unknown_command >expect && + echo "bogus 1 line of tokens" >in && + git config-batch >out in && + test_must_fail git config-batch 2>err Date: Sat, 17 Jan 2026 14:27:46 -0500 Subject: [PATCH 03/11] config-batch: implement get v1 The 'get' command for the 'git config-batch' builtin is the first command and is currently at version 1. It returns at most one value, the same as 'git config --get ' with optional value-based filtering. The documentation and tests detail the specifics of how to format requests of this format and how to parse the results. Future versions could consider multi-valued responses or regex-based key matching. For the sake of incremental exploration of the potential in the 'git config-batch' command, this is the only implementation being presented in the first patch series. Future extensions could include a '-z' parameter that uses NUL bytes in the command and output format to allow for spaces or newlines in the input or newlines in the output. Signed-off-by: Derrick Stolee --- Documentation/git-config-batch.adoc | 53 +++++- builtin/config-batch.c | 251 +++++++++++++++++++++++++++- config.h | 3 + t/t1312-config-batch.sh | 101 +++++++++++ 4 files changed, 405 insertions(+), 3 deletions(-) diff --git a/Documentation/git-config-batch.adoc b/Documentation/git-config-batch.adoc index 9ca04b0c1eafd2..31dd42f481678e 100644 --- a/Documentation/git-config-batch.adoc +++ b/Documentation/git-config-batch.adoc @@ -32,9 +32,58 @@ set. Thus, if the Git version includes the `git config-batch` builtin but doesn't understand an input command, it will return a single line response: -``` +------------ unknown_command LF -``` +------------ + +These are the commands that are currently understood: + +`get` version 1:: + The `get` command searches the config key-value pairs within a + given `` for values that match the fixed `` and + filters the resulting value based on an optional ``. + This can either be a regex or a fixed value. The command format + is one of the following formats: ++ +------------ +get 1 +get 1 arg:regex +get 1 arg:fixed-value +------------ ++ +The `` value can be one of `inherited`, `system`, `global`, +`local`, `worktree`, `submodule`, or `command`. If `inherited`, then all +config key-value pairs will be considered regardless of scope. Otherwise, +only the given scope will be considered. ++ +If no optional arguments are given, then the value will not be filtered +by any pattern matching. If `arg:regex` is specified, then the rest of +the line is considered a single string, ``, and is +interpreted as a regular expression for matching against stored values, +similar to specifying a value to `get config --get ""`. +If `arg:fixed-value` is specified, then the rest of the line is +considered a single string, ``, and is checked for an exact +match against the key-value pairs, simmilar to `git config --get +--fixed-value ""`. ++ +At mmost one key-value pair is returned, that being the last key-value +pair in the standard config order by scope and sequence within each scope. ++ +If a key-value pair is found, then the following output is given: ++ +------------ +get 1 found +------------ ++ +If no matching key-value pair is found, then the following output is +given: ++ +------------ +get 1 missing [|] +------------ ++ +where `` or `` is only supplied if provided in +the command. SEE ALSO -------- diff --git a/builtin/config-batch.c b/builtin/config-batch.c index dffedb8ca24199..5782004080601b 100644 --- a/builtin/config-batch.c +++ b/builtin/config-batch.c @@ -12,6 +12,8 @@ static const char *const builtin_config_batch_usage[] = { }; #define UNKNOWN_COMMAND "unknown_command" +#define GET_COMMAND "get" +#define COMMAND_PARSE_ERROR "command_parse_error" static int emit_response(const char *response, ...) { @@ -30,6 +32,11 @@ static int emit_response(const char *response, ...) return 0; } +static int command_parse_error(const char *command) +{ + return emit_response(COMMAND_PARSE_ERROR, command, NULL); +} + /** * A function pointer type for defining a command. The function is * responsible for handling different versions of the command name. @@ -46,11 +53,248 @@ typedef int (*command_fn)(struct repository *repo, char *data, size_t data_len); static int unknown_command(struct repository *repo UNUSED, - char *data UNUSED, size_t data_len UNUSED) + char *data UNUSED, size_t data_len UNUSED) { return emit_response(UNKNOWN_COMMAND, NULL); } +static size_t parse_whitespace_token(char **data, size_t *data_len, + char **token, int *err UNUSED) +{ + size_t i = 0; + + *token = *data; + + while (i < *data_len && (*data)[i] && (*data)[i] != ' ') + i++; + + if (i >= *data_len) { + *data_len = 0; + *data = NULL; + return i; + } + + (*data)[i] = 0; + *data_len = (*data_len) - (i + 1); + *data = *data + (i + 1); + return i; +} + +/** + * Given the remaining data line and its size, attempt to extract + * a token. When the token delimiter is determined, the data + * string is mutated to insert a NUL byte at the end of the token. + * The data pointer is mutated to point at the next character (or + * set to NULL if that exceeds the string length). The data_len + * value is mutated to subtract the length of the discovered + * token. + * + * The returned value is the length of the token that was + * discovered. + * + * 'err' is ignored for now, but will be filled in in a future + * change. + */ +static size_t parse_token(char **data, size_t *data_len, + char **token, int *err) +{ + if (!*data_len) + return 0; + + return parse_whitespace_token(data, data_len, token, err); +} + +enum value_match_mode { + MATCH_ALL, + MATCH_EXACT, + MATCH_REGEX, +}; + +struct get_command_1_data { + /* parameters */ + char *key; + enum config_scope scope; + enum value_match_mode mode; + + /* optional parameters */ + char *value; + regex_t *value_pattern; + + /* data along the way, for single values. */ + char *found; + enum config_scope found_scope; +}; + +static int get_command_1_cb(const char *key, const char *value, + const struct config_context *context, + void *data) +{ + struct get_command_1_data *d = data; + + if (strcasecmp(key, d->key)) + return 0; + + if (d->scope != CONFIG_SCOPE_UNKNOWN && + d->scope != context->kvi->scope) + return 0; + + switch (d->mode) { + case MATCH_EXACT: + if (strcasecmp(value, d->value)) + return 0; + break; + + case MATCH_REGEX: + if (regexec(d->value_pattern, value, 0, NULL, 0)) + return 0; + break; + + default: + break; + } + + free(d->found); + d->found = xstrdup(value); + d->found_scope = context->kvi->scope; + return 0; +} + +static const char *scope_str(enum config_scope scope) +{ + switch (scope) { + case CONFIG_SCOPE_UNKNOWN: + return "unknown"; + + case CONFIG_SCOPE_SYSTEM: + return "system"; + + case CONFIG_SCOPE_GLOBAL: + return "global"; + + case CONFIG_SCOPE_LOCAL: + return "local"; + + case CONFIG_SCOPE_WORKTREE: + return "worktree"; + + case CONFIG_SCOPE_SUBMODULE: + return "submodule"; + + case CONFIG_SCOPE_COMMAND: + return "command"; + + default: + BUG("invalid config scope"); + } +} + +static int parse_scope(const char *str, enum config_scope *scope) +{ + if (!strcmp(str, "inherited")) { + *scope = CONFIG_SCOPE_UNKNOWN; + return 0; + } + + for (enum config_scope s = 0; s < CONFIG_SCOPE__NR; s++) { + if (!strcmp(str, scope_str(s))) { + *scope = s; + return 0; + } + } + + return -1; +} + +/** + * 'get' command, version 1. + * + * Positional arguments should be of the form: + * + * [0] scope ("system", "global", "local", "worktree", "command", "submodule", or "inherited") + * [1] config key + * [2*] multi-mode ("regex", "fixed-value") + * [3*] value regex OR value string + * + * [N*] indicates optional parameters that are not needed. + */ +static int get_command_1(struct repository *repo, + char *data, + size_t data_len) +{ + struct get_command_1_data gc_data = { + .found = NULL, + .mode = MATCH_ALL, + }; + int res = 0, err = 0; + char *token; + size_t token_len; + + if (!parse_token(&data, &data_len, &token, &err) || err) + goto parse_error; + + if (parse_scope(token, &gc_data.scope)) + goto parse_error; + + if (!parse_token(&data, &data_len, &gc_data.key, &err) || err) + goto parse_error; + + token_len = parse_token(&data, &data_len, &token, &err); + if (err) + goto parse_error; + + if (token_len && !strncmp(token, "arg:", 4)) { + if (!strcmp(token + 4, "regex")) + gc_data.mode = MATCH_REGEX; + else if (!strcmp(token + 4, "fixed-value")) + gc_data.mode = MATCH_EXACT; + else + goto parse_error; /* unknown arg. */ + + /* Use the remaining data as the value string. */ + gc_data.value = data; + + if (gc_data.mode == MATCH_REGEX) { + CALLOC_ARRAY(gc_data.value_pattern, 1); + if (regcomp(gc_data.value_pattern, gc_data.value, + REG_EXTENDED)) { + FREE_AND_NULL(gc_data.value_pattern); + goto parse_error; + } + } + } else if (token_len) { + /* + * If we have remaining tokens not starting in "arg:", + * then we don't understand them. + */ + goto parse_error; + } + + repo_config(repo, get_command_1_cb, &gc_data); + + if (gc_data.found) + res = emit_response(GET_COMMAND, "1", "found", gc_data.key, + scope_str(gc_data.found_scope), + gc_data.found, + NULL); + else + res = emit_response(GET_COMMAND, "1", "missing", gc_data.key, + gc_data.value, NULL); + + goto cleanup; + + +parse_error: + res = command_parse_error(GET_COMMAND); + +cleanup: + if (gc_data.value_pattern) { + regfree(gc_data.value_pattern); + free(gc_data.value_pattern); + } + free(gc_data.found); + return res; +} + struct command { const char *name; command_fn fn; @@ -58,6 +302,11 @@ struct command { }; static struct command commands[] = { + { + .name = GET_COMMAND, + .fn = get_command_1, + .version = 1, + }, /* unknown_command must be last. */ { .name = "", diff --git a/config.h b/config.h index ba426a960af9f4..966a228f0e1a39 100644 --- a/config.h +++ b/config.h @@ -44,6 +44,9 @@ enum config_scope { CONFIG_SCOPE_WORKTREE, CONFIG_SCOPE_COMMAND, CONFIG_SCOPE_SUBMODULE, + + /* Must be last */ + CONFIG_SCOPE__NR }; const char *config_scope_name(enum config_scope scope); diff --git a/t/t1312-config-batch.sh b/t/t1312-config-batch.sh index f60ef35e38d7c1..e638b54d13fa5e 100755 --- a/t/t1312-config-batch.sh +++ b/t/t1312-config-batch.sh @@ -16,10 +16,111 @@ test_expect_success 'unknown_command' ' test_cmp expect out ' +test_expect_success 'completely broken input' ' + echo "not_even_two_tokens" >in && + test_must_fail git config-batch 2>err in && test_must_fail git config-batch 2>err in && + echo "get 1 found test.key local test value with spaces" >expect && + git config-batch >out in && + echo "get 1 missing test.key" >expect && + git config-batch >out in <<-\EOF && + get 1 inherited test.key arg:regex .*1.* + get 1 inherited test.key arg:regex [a-z]2.* + get 1 inherited test.key arg:regex .*3e s.* + get 1 inherited test.key arg:regex 4.* + get 1 inherited test.key arg:regex .*5.* + get 1 inherited test.key arg:regex .*6.* + EOF + + cat >expect <<-\EOF && + get 1 found test.key system on1e + get 1 found test.key global t2wo + get 1 found test.key local thre3e space + get 1 found test.key worktree 4four + get 1 found test.key command five5 + get 1 missing test.key .*6.* + EOF + + git -c test.key=five5 config-batch >out in <<-\EOF && + get 1 inherited test.key arg:fixed-value one + get 1 inherited test.key arg:fixed-value two + get 1 inherited test.key arg:fixed-value three space + get 1 inherited test.key arg:fixed-value four + get 1 inherited test.key arg:fixed-value five + get 1 inherited test.key arg:fixed-value six + EOF + + cat >expect <<-\EOF && + get 1 found test.key system one + get 1 found test.key global two + get 1 found test.key local three space + get 1 found test.key worktree four + get 1 found test.key command five + get 1 missing test.key six + EOF + + git -c test.key=five config-batch >out Date: Sun, 18 Jan 2026 13:49:35 -0500 Subject: [PATCH 04/11] config-batch: create 'help' command Tools that use the 'git config-batch' tool will want to know which commands are available in the current Git version. Having a 'help' command assists greatly to give a clear set of available commands and their versions. Signed-off-by: Derrick Stolee --- Documentation/git-config-batch.adoc | 17 +++++++++++++++ builtin/config-batch.c | 32 +++++++++++++++++++++++++++++ t/t1312-config-batch.sh | 13 ++++++++++++ 3 files changed, 62 insertions(+) diff --git a/Documentation/git-config-batch.adoc b/Documentation/git-config-batch.adoc index 31dd42f481678e..1fff68a13ce130 100644 --- a/Documentation/git-config-batch.adoc +++ b/Documentation/git-config-batch.adoc @@ -38,6 +38,23 @@ unknown_command LF These are the commands that are currently understood: +`help` version 1:: + The `help` command lists the currently-available commands in + this version of Git. The output is multi-line, but the first + line provides the count of possible commands via `help count `. + The next `` lines are of the form `help ` + to state that this Git version supports that `` at + version ``. Note that the same command may have multiple + available versions. ++ +Here is the currentl output of the help text at the latest version: ++ +------------ +help 1 count 2 +help 1 help 1 +help 1 get 1 +------------ + `get` version 1:: The `get` command searches the config key-value pairs within a given `` for values that match the fixed `` and diff --git a/builtin/config-batch.c b/builtin/config-batch.c index 5782004080601b..1c19e4889f1b20 100644 --- a/builtin/config-batch.c +++ b/builtin/config-batch.c @@ -12,6 +12,7 @@ static const char *const builtin_config_batch_usage[] = { }; #define UNKNOWN_COMMAND "unknown_command" +#define HELP_COMMAND "help" #define GET_COMMAND "get" #define COMMAND_PARSE_ERROR "command_parse_error" @@ -104,6 +105,9 @@ static size_t parse_token(char **data, size_t *data_len, return parse_whitespace_token(data, data_len, token, err); } +static int help_command_1(struct repository *repo, + char *data, size_t data_len); + enum value_match_mode { MATCH_ALL, MATCH_EXACT, @@ -302,6 +306,11 @@ struct command { }; static struct command commands[] = { + { + .name = HELP_COMMAND, + .fn = help_command_1, + .version = 1, + }, { .name = GET_COMMAND, .fn = get_command_1, @@ -316,6 +325,29 @@ static struct command commands[] = { #define COMMAND_COUNT ((size_t)(sizeof(commands) / sizeof(*commands))) +static int help_command_1(struct repository *repo UNUSED, + char *data UNUSED, size_t data_len UNUSED) +{ + struct strbuf fmt_str = STRBUF_INIT; + + strbuf_addf(&fmt_str, "%"PRIu32, (uint32_t)(COMMAND_COUNT - 1)); + emit_response(HELP_COMMAND, "1", "count", fmt_str.buf, NULL); + strbuf_reset(&fmt_str); + + for (size_t i = 0; i < COMMAND_COUNT; i++) { + /* Halt at unknown command. */ + if (!commands[i].name[0]) + break; + + strbuf_addf(&fmt_str, "%d", commands[i].version); + emit_response(HELP_COMMAND, "1", commands[i].name, fmt_str.buf, NULL); + strbuf_reset(&fmt_str); + } + + strbuf_release(&fmt_str); + return 0; +} + /** * Process a single line from stdin and process the command. * diff --git a/t/t1312-config-batch.sh b/t/t1312-config-batch.sh index e638b54d13fa5e..6b550a0e76d3c8 100755 --- a/t/t1312-config-batch.sh +++ b/t/t1312-config-batch.sh @@ -23,6 +23,19 @@ test_expect_success 'completely broken input' ' test_grep "an unrecoverable error occurred during command execution" err ' +test_expect_success 'help command' ' + echo "help 1" >in && + + cat >expect <<-\EOF && + help 1 count 2 + help 1 help 1 + help 1 get 1 + EOF + + git config-batch >out in && test_must_fail git config-batch 2>err Date: Wed, 21 Jan 2026 21:31:52 -0500 Subject: [PATCH 05/11] config-batch: add NUL-terminated I/O format When using automated tools, it is critical to allow for input/output formats that include special characters such as spaces and newlines. While the existing protocol for 'git config-batch' is human-readable and has some capacity for some spaces in certain positions, it is not available for spaces in the config key or newlines in the config values. Add the '-z' option to signal the use of NUL-terminated strings. To understand where commands end regardless of potential future formats, use two NUL bytes in a row to terminate a command. To allow for empty string values, each token is provided in a : format, making "0:" the empty string value. Update the existing 'help' and 'get' commands to match this format. Create helper methods that make it easy to parse and print in both formats simultaneously. Signed-off-by: Derrick Stolee --- Documentation/git-config-batch.adoc | 57 ++++++++- builtin/config-batch.c | 188 +++++++++++++++++++++++++--- t/t1312-config-batch.sh | 69 ++++++++++ 3 files changed, 293 insertions(+), 21 deletions(-) diff --git a/Documentation/git-config-batch.adoc b/Documentation/git-config-batch.adoc index 1fff68a13ce130..3c9a3bb7638c38 100644 --- a/Documentation/git-config-batch.adoc +++ b/Documentation/git-config-batch.adoc @@ -21,6 +21,15 @@ multiple configuration values, the `git config-batch` command allows a single process to handle multiple requests using a machine-parseable interface across `stdin` and `stdout`. +OPTIONS +------- + +`-z`:: + If specified, then use the NUL-terminated input and output + format instead of the space and newline format. This format is + useful when the strings involved may include spaces or newlines. + See PROTOCOL for more details. + PROTOCOL -------- By default, the protocol uses line feeds (`LF`) to signal the end of a @@ -41,13 +50,13 @@ These are the commands that are currently understood: `help` version 1:: The `help` command lists the currently-available commands in this version of Git. The output is multi-line, but the first - line provides the count of possible commands via `help count `. - The next `` lines are of the form `help ` + line provides the count of possible commands via `help 1 count `. + The next `` lines are of the form `help 1 ` to state that this Git version supports that `` at version ``. Note that the same command may have multiple available versions. + -Here is the currentl output of the help text at the latest version: +Here is the current output of the help text at the latest version: + ------------ help 1 count 2 @@ -102,6 +111,48 @@ get 1 missing [|] where `` or `` is only supplied if provided in the command. +NUL-Terminated Format +~~~~~~~~~~~~~~~~~~~~~ + +When `-z` is given, the protocol changes in some structural ways. + +First, each command is terminated with two NUL bytes, providing a clear +boundary between commands regardless of future possibilities of new +command formats. + +Second, any time that a space _would_ be used to partition tokens in a +command, a NUL byte is used instead. Further, each token is prefixed +with `:` where `` is a decimal representation of the length of +the string between the `:` and the next NUL byte. Any disagreement in +these lengths is treated as a parsing error. This use of a length does +imply that "`0:`" is the representation of an empty string, if relevant. + +The decimal representation must have at most five numerals, thus the +maximum length of a string token can have 99999 characters. + +For example, the `get` command, version 1, could have any of the +following forms: + +------------ +3:get NUL 1:1 NUL 5:local NUL 14:key.with space NUL NUL +3:get NUL 1:1 NUL 9:inherit NUL 8:test.key NUL 9:arg:regex NUL 6:.*\ .* NUL NUL +3:get NUL 1:1 NUL 6:global NUL 8:test.key NUL 15:arg:fixed-value NUL 3:a b NUL NUL +------------ + +The output is modified similarly, such as the following output examples, +as if the input has a parse error, a valid `help` command, a `get` +command that had a match, and a `get` command that did not match. + +------------ +15:unknown_command NUL NUL +4:help NUL 1:1 NUL 5:count NUL 1:2 NUL NUL +4:help NUL 1:1 NUL 4:help NUL 1:1 NUL NUL +4:help NUL 1:1 NUL 3:get NUL 1:1 NUL NUL +3:get NUL 1:1 NUL 5:found NUL 8:test.key NUL 5:value NUL NUL +3:get NUL 1:1 NUL 7:missing NUL 8:test.key NUL NUL +------------ + + SEE ALSO -------- linkgit:git-config[1] diff --git a/builtin/config-batch.c b/builtin/config-batch.c index 1c19e4889f1b20..2c48c4ea3751fa 100644 --- a/builtin/config-batch.c +++ b/builtin/config-batch.c @@ -11,24 +11,40 @@ static const char *const builtin_config_batch_usage[] = { NULL }; +static int zformat = 0; + #define UNKNOWN_COMMAND "unknown_command" #define HELP_COMMAND "help" #define GET_COMMAND "get" #define COMMAND_PARSE_ERROR "command_parse_error" +static void print_word(const char *word, int start) +{ + if (zformat) { + printf("%"PRIu32":%s", (uint32_t)strlen(word), word); + fputc(0, stdout); + } else if (start) + printf("%s", word); + else + printf(" %s", word); +} + static int emit_response(const char *response, ...) { va_list params; const char *token; - printf("%s", response); + print_word(response, 1); va_start(params, response); while ((token = va_arg(params, const char *))) - printf(" %s", token); + print_word(token, 0); va_end(params); - printf("\n"); + if (zformat) + fputc(0, stdout); + else + printf("\n"); fflush(stdout); return 0; } @@ -59,6 +75,52 @@ static int unknown_command(struct repository *repo UNUSED, return emit_response(UNKNOWN_COMMAND, NULL); } +/* + * Parse the next token using the NUL-byte format. + */ +static size_t parse_ztoken(char **data, size_t *data_len, + char **token, int *err) +{ + size_t i = 0, token_len; + + while (i < *data_len && (*data)[i] != ':') { + if ((*data)[i] < '0' || (*data)[i] > '9') { + goto parse_error; + } + i++; + } + + if (i >= *data_len || (*data)[i] != ':' || i > 5) + goto parse_error; + + (*data)[i] = 0; + token_len = atoi(*data); + + if (token_len + i + 1 >= *data_len) + goto parse_error; + + *token = *data + i + 1; + *data_len = *data_len - (i + 1); + + /* check for early NULs. */ + for (i = 0; i < token_len; i++) { + if (!(*token)[i]) + goto parse_error; + } + /* check for matching NUL. */ + if ((*token)[token_len]) + goto parse_error; + + *data = *token + token_len + 1; + *data_len = *data_len - (token_len + 1); + return token_len; + +parse_error: + *err = 1; + *token = NULL; + return 0; +} + static size_t parse_whitespace_token(char **data, size_t *data_len, char **token, int *err UNUSED) { @@ -93,15 +155,23 @@ static size_t parse_whitespace_token(char **data, size_t *data_len, * The returned value is the length of the token that was * discovered. * - * 'err' is ignored for now, but will be filled in in a future - * change. + * The 'token' pointer is used to set the start of the token. + * In the whitespace format, this is always the input value of + * 'data' but in the NUL-terminated format this follows an ":" + * prefix. + * + * In the case of the NUL-terminated format, a bad parse of the + * decimal length or a mismatch of the decimal length and the + * length of the following NUL-terminated string will result in + * the value pointed at by 'err' to be set to 1. */ static size_t parse_token(char **data, size_t *data_len, char **token, int *err) { if (!*data_len) return 0; - + if (zformat) + return parse_ztoken(data, data_len, token, err); return parse_whitespace_token(data, data_len, token, err); } @@ -255,7 +325,13 @@ static int get_command_1(struct repository *repo, goto parse_error; /* unknown arg. */ /* Use the remaining data as the value string. */ - gc_data.value = data; + if (!zformat) + gc_data.value = data; + else { + parse_token(&data, &data_len, &gc_data.value, &err); + if (err) + goto parse_error; + } if (gc_data.mode == MATCH_REGEX) { CALLOC_ARRAY(gc_data.value_pattern, 1); @@ -348,17 +424,74 @@ static int help_command_1(struct repository *repo UNUSED, return 0; } -/** - * Process a single line from stdin and process the command. - * - * Returns 0 on successful processing of command, including the - * unknown_command output. - * - * Returns 1 on natural exit due to exist signal of empty line. - * - * Returns negative value on other catastrophic error. - */ -static int process_command(struct repository *repo) +static int process_command_nul(struct repository *repo) +{ + static struct strbuf line = STRBUF_INIT; + char *data, *command, *versionstr; + size_t data_len, token_len; + int res = 0, err = 0, version = 0, getc; + char c; + + /* If we start with EOF it's not an error. */ + getc = fgetc(stdin); + if (getc == EOF) + return 1; + + do { + c = (char)getc; + strbuf_addch(&line, c); + + if (!c && line.len > 1 && !line.buf[line.len - 2]) + break; + + getc = fgetc(stdin); + + /* It's an error if we reach EOF while parsing a command. */ + if (getc == EOF) + goto parse_error; + } while (1); + + data = line.buf; + data_len = line.len - 1; + + token_len = parse_ztoken(&data, &data_len, &command, &err); + if (!token_len || err) + goto parse_error; + + token_len = parse_ztoken(&data, &data_len, &versionstr, &err); + if (!token_len || err) + goto parse_error; + + if (!git_parse_int(versionstr, &version)) { + res = error(_("unable to parse '%s' to integer"), + versionstr); + goto parse_error; + } + + for (size_t i = 0; i < COMMAND_COUNT; i++) { + /* + * Run the ith command if we have hit the unknown + * command or if the name and version match. + */ + if (!commands[i].name[0] || + (!strcmp(command, commands[i].name) && + commands[i].version == version)) { + res = commands[i].fn(repo, data, data_len); + goto cleanup; + } + } + + BUG(_("scanned to end of command list, including 'unknown_command'")); + +parse_error: + res = unknown_command(repo, NULL, 0); + +cleanup: + strbuf_release(&line); + return res; +} + +static int process_command_whitespace(struct repository *repo) { static struct strbuf line = STRBUF_INIT; struct string_list tokens = STRING_LIST_INIT_NODUP; @@ -416,6 +549,23 @@ static int process_command(struct repository *repo) return res; } +/** + * Process a single line from stdin and process the command. + * + * Returns 0 on successful processing of command, including the + * unknown_command output. + * + * Returns 1 on natural exit due to exist signal of empty line. + * + * Returns negative value on other catastrophic error. + */ +static int process_command(struct repository *repo) +{ + if (zformat) + return process_command_nul(repo); + return process_command_whitespace(repo); +} + int cmd_config_batch(int argc, const char **argv, const char *prefix, @@ -423,6 +573,8 @@ int cmd_config_batch(int argc, { int res = 0; struct option options[] = { + OPT_BOOL('z', NULL, &zformat, + N_("stdin and stdout is NUL-terminated")), OPT_END(), }; diff --git a/t/t1312-config-batch.sh b/t/t1312-config-batch.sh index 6b550a0e76d3c8..f7a74ddc2cb94b 100755 --- a/t/t1312-config-batch.sh +++ b/t/t1312-config-batch.sh @@ -4,6 +4,26 @@ test_description='Test git config-batch' . ./test-lib.sh +# usage: test_zformat out +# +# Let 'in' be a z-format input but with " NUL " between tokens in +# a single command and " NUL NUL" trailing each line. +# +# The values in 'out' will be space- and newline-delimited where +# NUL-bytes would normally be output. +test_zformat () { + sed -e "s/\ NUL\ /!/g" >nullin1 && + sed -e "s/NUL//g" nullin2 && + + tr "!" "\0" nullin3 && + tr "\n" "\0" zin && + + $* zout && + + tr "\0" " " outspace && + sed "s/\ \ /\n/g" out && test_must_be_empty out @@ -36,6 +56,23 @@ test_expect_success 'help command' ' test_cmp expect out ' +test_expect_success 'help -z' ' + cat >in <<-\EOF && + 4:help NUL 1:1 NUL NUL + 5:bogus NUL 2:10 NUL NUL + EOF + + cat >expect <<-\EOF && + 4:help 1:1 5:count 1:2 + 4:help 1:1 4:help 1:1 + 4:help 1:1 3:get 1:1 + 15:unknown_command + EOF + + test_zformat git config-batch -z >out in && test_must_fail git config-batch 2>err in <<-\EOF && + 3:get NUL 1:1 NUL 9:inherited NUL 8:test.key NUL NUL + 3:get NUL 1:1 NUL 6:global NUL 8:test.key NUL 9:arg:regex NUL 3:2.* NUL NUL + 3:get NUL 1:1 NUL 5:local NUL 8:test.key NUL 15:arg:fixed-value NUL 12:thre3e space NUL NUL + 3:get NUL 1:1 NUL 9:inherited NUL 11:key.missing NUL NUL + EOF + + cat >expect <<-\EOF && + 3:get 1:1 5:found 8:test.key 8:worktree 5:4four + 3:get 1:1 5:found 8:test.key 6:global 4:t2wo + 3:get 1:1 5:found 8:test.key 5:local 12:thre3e space + 3:get 1:1 7:missing 11:key.missing + EOF + + test_zformat git config-batch -z >out Date: Mon, 2 Feb 2026 09:51:08 -0500 Subject: [PATCH 06/11] docs: add design doc for config-batch This document will be a place that tracks the future directions of the 'git config-batch' builtin. We plan to remove items as they are implemented in new commands and documented in the builtin documentation. Signed-off-by: Derrick Stolee --- Documentation/technical/config-batch.adoc | 70 +++++++++++++++++++++++ 1 file changed, 70 insertions(+) create mode 100644 Documentation/technical/config-batch.adoc diff --git a/Documentation/technical/config-batch.adoc b/Documentation/technical/config-batch.adoc new file mode 100644 index 00000000000000..dbd614ad4f7dd2 --- /dev/null +++ b/Documentation/technical/config-batch.adoc @@ -0,0 +1,70 @@ +Git Config-Batch Design Notes +============================= + +The `git config-batch` builtin has a robust protocol for parsing multiple +commands over `stdin` and providing structured output over `stdout`. The +intended use is for scripts or third-party software to interact with the +config settings of a repository multiple times within the same Git process. +The protocol is built with versioning that allows the consumer to know when +a certain command is available and to fall back to single-use `git config` +processes if the installed Git version does not have the latest commands +at the required versions. + +Recommended interaction pattern +------------------------------- + +This section provides a guide for ideal interaction with the `git +config-batch` command and its protocol. + +For maximum compatibility, do not attempt parsing the output of `git +version` to determine which commands are available. Instead, first check +if the `git config-batch` command succeeds and does not die immediately +due to the builtin being unavailable. Then, use the v1 of the `help` +command to get a list of available commands and versions. Use this list to +determine if your capabilities are available or should be replaced with an +appropriate `git config` single-use process. + +Further, all automated tooling would be better off using the +NUL-terminated format instead of the whitespace-delimited format, in case +config keys contain spaces or config values contain newlines. The +whitespace-delimited version is available for simpler integration and +human inspection. + +Current commands +---------------- + +See the documentation in linkgit::config-batch[1] for the latest set of +available commands and their protocols. + +Future commands +--------------- + +The following modes of `git config` are not currently available as commands +in `git config-batch`, but are planned for future integration: + +`git config list [--]`:: + Getting all values, regardless of config key, would require a + multi-valued output similar to the `help` command. This tool will + likely assume advanced options such as `--show-origin`. + +`git config set [--] `:: + It will be desirable to set a config key at a given scope as a + single value, replacing the current value at that scope, if it + exists and is a single value. A `set` command could satisfy this + purpose. + +`git config set --all [|--fixed-value=] `:: + When replacing multiple values, it may be necessary to have a different + output describing the places those values were set, so it may need to + be implemented via a `set-all` command to differentiate from a `set` + command. + +`git config unset `:: + +`git config unset --all [|--fixed-value=] `:: + +`git config get --all --rexexp []`:: + +`--replace-all` option:: + +`--type=` option:: From 4be089a4dda63fdc0ea2db00acb47b33befe07ef Mon Sep 17 00:00:00 2001 From: Derrick Stolee Date: Sun, 25 Jan 2026 10:11:44 -0500 Subject: [PATCH 07/11] config: extract location structs from builtin Before reusing these concepts in builtin/config-batch.c, extract the config_location_options struct from builtin/config.c to config.h with implementation in config.c. The only modification in this conversion is the use of a repository parameter instead of the_repository. Signed-off-by: Derrick Stolee --- builtin/config.c | 117 ++++------------------------------------------- config.c | 89 +++++++++++++++++++++++++++++++++++ config.h | 20 ++++++++ 3 files changed, 117 insertions(+), 109 deletions(-) diff --git a/builtin/config.c b/builtin/config.c index 288ebdfdaaab1c..d129b1204d0772 100644 --- a/builtin/config.c +++ b/builtin/config.c @@ -71,20 +71,6 @@ static const char *const builtin_config_edit_usage[] = { OPT_STRING('f', "file", &opts.source.file, N_("file"), N_("use given config file")), \ OPT_STRING(0, "blob", &opts.source.blob, N_("blob-id"), N_("read config from given blob object")) -struct config_location_options { - struct git_config_source source; - struct config_options options; - char *file_to_free; - int use_global_config; - int use_system_config; - int use_local_config; - int use_worktree_config; - int respect_includes_opt; -}; -#define CONFIG_LOCATION_OPTIONS_INIT { \ - .respect_includes_opt = -1, \ -} - #define CONFIG_TYPE_OPTIONS(type) \ OPT_GROUP(N_("Type")), \ OPT_CALLBACK('t', "type", &type, N_("type"), N_("value is given this type"), option_parse_type), \ @@ -772,93 +758,6 @@ static char *default_user_config(void) return strbuf_detach(&buf, NULL); } -static void location_options_init(struct config_location_options *opts, - const char *prefix) -{ - if (!opts->source.file) - opts->source.file = opts->file_to_free = - xstrdup_or_null(getenv(CONFIG_ENVIRONMENT)); - - if (opts->use_global_config + opts->use_system_config + - opts->use_local_config + opts->use_worktree_config + - !!opts->source.file + !!opts->source.blob > 1) { - error(_("only one config file at a time")); - exit(129); - } - - if (!startup_info->have_repository) { - if (opts->use_local_config) - die(_("--local can only be used inside a git repository")); - if (opts->source.blob) - die(_("--blob can only be used inside a git repository")); - if (opts->use_worktree_config) - die(_("--worktree can only be used inside a git repository")); - } - - if (opts->source.file && - !strcmp(opts->source.file, "-")) { - opts->source.file = NULL; - opts->source.use_stdin = 1; - opts->source.scope = CONFIG_SCOPE_COMMAND; - } - - if (opts->use_global_config) { - opts->source.file = opts->file_to_free = git_global_config(); - if (!opts->source.file) - /* - * It is unknown if HOME/.gitconfig exists, so - * we do not know if we should write to XDG - * location; error out even if XDG_CONFIG_HOME - * is set and points at a sane location. - */ - die(_("$HOME not set")); - opts->source.scope = CONFIG_SCOPE_GLOBAL; - } else if (opts->use_system_config) { - opts->source.file = opts->file_to_free = git_system_config(); - opts->source.scope = CONFIG_SCOPE_SYSTEM; - } else if (opts->use_local_config) { - opts->source.file = opts->file_to_free = repo_git_path(the_repository, "config"); - opts->source.scope = CONFIG_SCOPE_LOCAL; - } else if (opts->use_worktree_config) { - struct worktree **worktrees = get_worktrees(); - if (the_repository->repository_format_worktree_config) - opts->source.file = opts->file_to_free = - repo_git_path(the_repository, "config.worktree"); - else if (worktrees[0] && worktrees[1]) - die(_("--worktree cannot be used with multiple " - "working trees unless the config\n" - "extension worktreeConfig is enabled. " - "Please read \"CONFIGURATION FILE\"\n" - "section in \"git help worktree\" for details")); - else - opts->source.file = opts->file_to_free = - repo_git_path(the_repository, "config"); - opts->source.scope = CONFIG_SCOPE_LOCAL; - free_worktrees(worktrees); - } else if (opts->source.file) { - if (!is_absolute_path(opts->source.file) && prefix) - opts->source.file = opts->file_to_free = - prefix_filename(prefix, opts->source.file); - opts->source.scope = CONFIG_SCOPE_COMMAND; - } else if (opts->source.blob) { - opts->source.scope = CONFIG_SCOPE_COMMAND; - } - - if (opts->respect_includes_opt == -1) - opts->options.respect_includes = !opts->source.file; - else - opts->options.respect_includes = opts->respect_includes_opt; - if (startup_info->have_repository) { - opts->options.commondir = repo_get_common_dir(the_repository); - opts->options.git_dir = repo_get_git_dir(the_repository); - } -} - -static void location_options_release(struct config_location_options *opts) -{ - free(opts->file_to_free); -} - static void display_options_init(struct config_display_options *opts) { if (opts->end_nul) { @@ -885,7 +784,7 @@ static int cmd_config_list(int argc, const char **argv, const char *prefix, argc = parse_options(argc, argv, prefix, opts, builtin_config_list_usage, 0); check_argc(argc, 0, 0); - location_options_init(&location_opts, prefix); + location_options_init(the_repository, &location_opts, prefix); display_options_init(&display_opts); setup_auto_pager("config", 1); @@ -944,7 +843,7 @@ static int cmd_config_get(int argc, const char **argv, const char *prefix, value_pattern)) die(_("--url= cannot be used with --all, --regexp or --value")); - location_options_init(&location_opts, prefix); + location_options_init(the_repository, &location_opts, prefix); display_options_init(&display_opts); if (display_opts.type != TYPE_COLOR) @@ -998,7 +897,7 @@ static int cmd_config_set(int argc, const char **argv, const char *prefix, comment = git_config_prepare_comment_string(comment_arg); - location_options_init(&location_opts, prefix); + location_options_init(the_repository, &location_opts, prefix); check_write(&location_opts.source); value = normalize_value(argv[0], argv[1], type, &default_kvi); @@ -1044,7 +943,7 @@ static int cmd_config_unset(int argc, const char **argv, const char *prefix, if ((flags & CONFIG_FLAGS_FIXED_VALUE) && !value_pattern) die(_("--fixed-value only applies with 'value-pattern'")); - location_options_init(&location_opts, prefix); + location_options_init(the_repository, &location_opts, prefix); check_write(&location_opts.source); if ((flags & CONFIG_FLAGS_MULTI_REPLACE) || value_pattern) @@ -1073,7 +972,7 @@ static int cmd_config_rename_section(int argc, const char **argv, const char *pr PARSE_OPT_STOP_AT_NON_OPTION); check_argc(argc, 2, 2); - location_options_init(&location_opts, prefix); + location_options_init(the_repository, &location_opts, prefix); check_write(&location_opts.source); ret = repo_config_rename_section_in_file(the_repository, location_opts.source.file, @@ -1103,7 +1002,7 @@ static int cmd_config_remove_section(int argc, const char **argv, const char *pr PARSE_OPT_STOP_AT_NON_OPTION); check_argc(argc, 1, 1); - location_options_init(&location_opts, prefix); + location_options_init(the_repository, &location_opts, prefix); check_write(&location_opts.source); ret = repo_config_rename_section_in_file(the_repository, location_opts.source.file, @@ -1163,7 +1062,7 @@ static int cmd_config_edit(int argc, const char **argv, const char *prefix, argc = parse_options(argc, argv, prefix, opts, builtin_config_edit_usage, 0); check_argc(argc, 0, 0); - location_options_init(&location_opts, prefix); + location_options_init(the_repository, &location_opts, prefix); check_write(&location_opts.source); ret = show_editor(&location_opts); @@ -1231,7 +1130,7 @@ static int cmd_config_actions(int argc, const char **argv, const char *prefix) builtin_config_usage, PARSE_OPT_STOP_AT_NON_OPTION); - location_options_init(&location_opts, prefix); + location_options_init(the_repository, &location_opts, prefix); display_options_init(&display_opts); if ((actions & (ACTION_GET_COLOR|ACTION_GET_COLORBOOL)) && display_opts.type) { diff --git a/config.c b/config.c index 7f6d53b4737cd8..9f1a7b45cfb352 100644 --- a/config.c +++ b/config.c @@ -35,6 +35,7 @@ #include "strvec.h" #include "trace2.h" #include "wildmatch.h" +#include "worktree.h" #include "write-or-die.h" struct config_source { @@ -3592,3 +3593,91 @@ int lookup_config(const char **mapping, int nr_mapping, const char *var) } return -1; } + +void location_options_init(struct repository *repo, + struct config_location_options *opts, + const char *prefix) +{ + if (!opts->source.file) + opts->source.file = opts->file_to_free = + xstrdup_or_null(getenv(CONFIG_ENVIRONMENT)); + + if (opts->use_global_config + opts->use_system_config + + opts->use_local_config + opts->use_worktree_config + + !!opts->source.file + !!opts->source.blob > 1) { + error(_("only one config file at a time")); + exit(129); + } + + if (!startup_info->have_repository) { + if (opts->use_local_config) + die(_("--local can only be used inside a git repository")); + if (opts->source.blob) + die(_("--blob can only be used inside a git repository")); + if (opts->use_worktree_config) + die(_("--worktree can only be used inside a git repository")); + } + + if (opts->source.file && + !strcmp(opts->source.file, "-")) { + opts->source.file = NULL; + opts->source.use_stdin = 1; + opts->source.scope = CONFIG_SCOPE_COMMAND; + } + + if (opts->use_global_config) { + opts->source.file = opts->file_to_free = git_global_config(); + if (!opts->source.file) + /* + * It is unknown if HOME/.gitconfig exists, so + * we do not know if we should write to XDG + * location; error out even if XDG_CONFIG_HOME + * is set and points at a sane location. + */ + die(_("$HOME not set")); + opts->source.scope = CONFIG_SCOPE_GLOBAL; + } else if (opts->use_system_config) { + opts->source.file = opts->file_to_free = git_system_config(); + opts->source.scope = CONFIG_SCOPE_SYSTEM; + } else if (opts->use_local_config) { + opts->source.file = opts->file_to_free = repo_git_path(repo, "config"); + opts->source.scope = CONFIG_SCOPE_LOCAL; + } else if (opts->use_worktree_config) { + struct worktree **worktrees = get_worktrees(); + if (repo->repository_format_worktree_config) + opts->source.file = opts->file_to_free = + repo_git_path(repo, "config.worktree"); + else if (worktrees[0] && worktrees[1]) + die(_("--worktree cannot be used with multiple " + "working trees unless the config\n" + "extension worktreeConfig is enabled. " + "Please read \"CONFIGURATION FILE\"\n" + "section in \"git help worktree\" for details")); + else + opts->source.file = opts->file_to_free = + repo_git_path(repo, "config"); + opts->source.scope = CONFIG_SCOPE_LOCAL; + free_worktrees(worktrees); + } else if (opts->source.file) { + if (!is_absolute_path(opts->source.file) && prefix) + opts->source.file = opts->file_to_free = + prefix_filename(prefix, opts->source.file); + opts->source.scope = CONFIG_SCOPE_COMMAND; + } else if (opts->source.blob) { + opts->source.scope = CONFIG_SCOPE_COMMAND; + } + + if (opts->respect_includes_opt == -1) + opts->options.respect_includes = !opts->source.file; + else + opts->options.respect_includes = opts->respect_includes_opt; + if (startup_info->have_repository) { + opts->options.commondir = repo_get_common_dir(repo); + opts->options.git_dir = repo_get_git_dir(repo); + } +} + +void location_options_release(struct config_location_options *opts) +{ + free(opts->file_to_free); +} diff --git a/config.h b/config.h index 966a228f0e1a39..6663964977f8bb 100644 --- a/config.h +++ b/config.h @@ -166,6 +166,26 @@ struct config_context { typedef int (*config_fn_t)(const char *, const char *, const struct config_context *, void *); +struct config_location_options { + struct git_config_source source; + struct config_options options; + char *file_to_free; + int use_global_config; + int use_system_config; + int use_local_config; + int use_worktree_config; + int respect_includes_opt; +}; +#define CONFIG_LOCATION_OPTIONS_INIT { \ + .respect_includes_opt = -1, \ +} + +void location_options_init(struct repository *repo, + struct config_location_options *opts, + const char *prefix); + +void location_options_release(struct config_location_options *opts); + /** * Read a specific file in git-config format. * This function takes the same callback and data parameters as `repo_config`. From 60443c56f456ca794e299ae8d8bbea23793780b5 Mon Sep 17 00:00:00 2001 From: Derrick Stolee Date: Sun, 25 Jan 2026 10:20:19 -0500 Subject: [PATCH 08/11] config-batch: pass prefix through commands The 'help' and 'get' commands of 'git config-batch' have not needed the prefix parameter from the builtin entrance point, but an upcoming command will need it in order to identify the location of the appropriate config file. Pass it through the appropriate functions and function pointers. Signed-off-by: Derrick Stolee --- builtin/config-batch.c | 26 +++++++++++++++++--------- 1 file changed, 17 insertions(+), 9 deletions(-) diff --git a/builtin/config-batch.c b/builtin/config-batch.c index 2c48c4ea3751fa..9829b16c6f6510 100644 --- a/builtin/config-batch.c +++ b/builtin/config-batch.c @@ -67,9 +67,11 @@ static int command_parse_error(const char *command) * Return 0 on success. */ typedef int (*command_fn)(struct repository *repo, + const char *prefix, char *data, size_t data_len); static int unknown_command(struct repository *repo UNUSED, + const char *prefix UNUSED, char *data UNUSED, size_t data_len UNUSED) { return emit_response(UNKNOWN_COMMAND, NULL); @@ -176,6 +178,7 @@ static size_t parse_token(char **data, size_t *data_len, } static int help_command_1(struct repository *repo, + const char *prefix UNUSED, char *data, size_t data_len); enum value_match_mode { @@ -292,6 +295,7 @@ static int parse_scope(const char *str, enum config_scope *scope) * [N*] indicates optional parameters that are not needed. */ static int get_command_1(struct repository *repo, + const char *prefix UNUSED, char *data, size_t data_len) { @@ -402,6 +406,7 @@ static struct command commands[] = { #define COMMAND_COUNT ((size_t)(sizeof(commands) / sizeof(*commands))) static int help_command_1(struct repository *repo UNUSED, + const char *prefix UNUSED, char *data UNUSED, size_t data_len UNUSED) { struct strbuf fmt_str = STRBUF_INIT; @@ -424,7 +429,8 @@ static int help_command_1(struct repository *repo UNUSED, return 0; } -static int process_command_nul(struct repository *repo) +static int process_command_nul(struct repository *repo, + const char *prefix) { static struct strbuf line = STRBUF_INIT; char *data, *command, *versionstr; @@ -476,7 +482,7 @@ static int process_command_nul(struct repository *repo) if (!commands[i].name[0] || (!strcmp(command, commands[i].name) && commands[i].version == version)) { - res = commands[i].fn(repo, data, data_len); + res = commands[i].fn(repo, prefix, data, data_len); goto cleanup; } } @@ -484,14 +490,15 @@ static int process_command_nul(struct repository *repo) BUG(_("scanned to end of command list, including 'unknown_command'")); parse_error: - res = unknown_command(repo, NULL, 0); + res = unknown_command(repo, prefix, NULL, 0); cleanup: strbuf_release(&line); return res; } -static int process_command_whitespace(struct repository *repo) +static int process_command_whitespace(struct repository *repo, + const char *prefix) { static struct strbuf line = STRBUF_INIT; struct string_list tokens = STRING_LIST_INIT_NODUP; @@ -536,7 +543,7 @@ static int process_command_whitespace(struct repository *repo) if (!commands[i].name[0] || (!strcmp(command, commands[i].name) && commands[i].version == version)) { - res = commands[i].fn(repo, data, data_len); + res = commands[i].fn(repo, prefix, data, data_len); goto cleanup; } } @@ -559,11 +566,12 @@ static int process_command_whitespace(struct repository *repo) * * Returns negative value on other catastrophic error. */ -static int process_command(struct repository *repo) +static int process_command(struct repository *repo, + const char *prefix) { if (zformat) - return process_command_nul(repo); - return process_command_whitespace(repo); + return process_command_nul(repo, prefix); + return process_command_whitespace(repo, prefix); } int cmd_config_batch(int argc, @@ -586,7 +594,7 @@ int cmd_config_batch(int argc, repo_config(repo, git_default_config, NULL); - while (!(res = process_command(repo))); + while (!(res = process_command(repo, prefix))); if (res == 1) return 0; From fdeef536f649bec811e8335d1c7151be8e352ff0 Mon Sep 17 00:00:00 2001 From: Derrick Stolee Date: Sun, 25 Jan 2026 11:05:16 -0500 Subject: [PATCH 09/11] config-batch: add 'set' v1 command This new command is intended for single-value assignments to a specific chosen scope. More complicated versions of the 'git config set' command will be incorporated into future commands. Signed-off-by: Derrick Stolee --- Documentation/git-config-batch.adoc | 24 ++++++++ builtin/config-batch.c | 71 ++++++++++++++++++++++ config.c | 27 +++++++++ config.h | 3 + t/t1312-config-batch.sh | 94 ++++++++++++++++++++++++++++- 5 files changed, 217 insertions(+), 2 deletions(-) diff --git a/Documentation/git-config-batch.adoc b/Documentation/git-config-batch.adoc index 3c9a3bb7638c38..feec85c4ef8fb4 100644 --- a/Documentation/git-config-batch.adoc +++ b/Documentation/git-config-batch.adoc @@ -111,6 +111,30 @@ get 1 missing [|] where `` or `` is only supplied if provided in the command. +`set` version 1:: + The `set` command writes a single key-value pair to a config + file. It specifies which file by a `` parameter from + among `system`, `global`, `local`, and `worktree`. The `` + is the next positional argument. The remaining data in the line + is provided as the `` to assign the config. ++ +------------ +set 1 +------------ ++ +These uses will match the behavior of `git config --set -- +`. Note that replacing all values with the `--all` option or +matching specific value patterns are not supported by this command. ++ +The response of these commands will include a `success` message if the +value is written as expected or `failed` if an unexpected failure +occurs: ++ +------------ +set 1 success +set 1 failed +------------ + NUL-Terminated Format ~~~~~~~~~~~~~~~~~~~~~ diff --git a/builtin/config-batch.c b/builtin/config-batch.c index 9829b16c6f6510..373b0cad47e8d5 100644 --- a/builtin/config-batch.c +++ b/builtin/config-batch.c @@ -16,6 +16,7 @@ static int zformat = 0; #define UNKNOWN_COMMAND "unknown_command" #define HELP_COMMAND "help" #define GET_COMMAND "get" +#define SET_COMMAND "set" #define COMMAND_PARSE_ERROR "command_parse_error" static void print_word(const char *word, int start) @@ -379,6 +380,71 @@ static int get_command_1(struct repository *repo, return res; } + +/** + * 'set' command, version 1. + * + * Positional arguments should be of the form: + * + * [0] scope ("system", "global", "local", or "worktree") + * [1] config key + * [2] config value + */ +static int set_command_1(struct repository *repo, + const char *prefix, + char *data, + size_t data_len) +{ + int res = 0, err = 0; + enum config_scope scope = CONFIG_SCOPE_UNKNOWN; + char *token = NULL, *key = NULL, *value = NULL; + struct config_location_options locopts = CONFIG_LOCATION_OPTIONS_INIT; + + if (!parse_token(&data, &data_len, &token, &err) || err) + goto parse_error; + + if (parse_scope(token, &scope) || + scope == CONFIG_SCOPE_UNKNOWN || + scope == CONFIG_SCOPE_SUBMODULE || + scope == CONFIG_SCOPE_COMMAND) + goto parse_error; + + if (!parse_token(&data, &data_len, &key, &err) || err) + goto parse_error; + + /* Use the remaining data as the value string. */ + if (!zformat) + value = data; + else { + parse_token(&data, &data_len, &value, &err); + if (err) + goto parse_error; + } + + if (location_options_set_scope(&locopts, scope)) + goto parse_error; + location_options_init(repo, &locopts, prefix); + + res = repo_config_set_in_file_gently(repo, locopts.source.file, + key, NULL, value); + + if (res) + res = emit_response(SET_COMMAND, "1", "failure", + scope_str(scope), key, value, NULL); + else + res = emit_response(SET_COMMAND, "1", "success", + scope_str(scope), key, value, NULL); + + goto cleanup; + +parse_error: + res = command_parse_error(SET_COMMAND); + +cleanup: + location_options_release(&locopts); + return res; +} + struct command { const char *name; command_fn fn; @@ -396,6 +462,11 @@ static struct command commands[] = { .fn = get_command_1, .version = 1, }, + { + .name = SET_COMMAND, + .fn = set_command_1, + .version = 1, + }, /* unknown_command must be last. */ { .name = "", diff --git a/config.c b/config.c index 9f1a7b45cfb352..fa72234750b187 100644 --- a/config.c +++ b/config.c @@ -3594,6 +3594,33 @@ int lookup_config(const char **mapping, int nr_mapping, const char *var) return -1; } +int location_options_set_scope(struct config_location_options *opts, + enum config_scope scope) +{ + switch (scope) { + case CONFIG_SCOPE_SYSTEM: + opts->use_system_config = 1; + break; + + case CONFIG_SCOPE_GLOBAL: + opts->use_global_config = 1; + break; + + case CONFIG_SCOPE_LOCAL: + opts->use_local_config = 1; + break; + + case CONFIG_SCOPE_WORKTREE: + opts->use_worktree_config = 1; + break; + + default: + return -1; + } + + return 0; +} + void location_options_init(struct repository *repo, struct config_location_options *opts, const char *prefix) diff --git a/config.h b/config.h index 6663964977f8bb..f6432c1ec26e60 100644 --- a/config.h +++ b/config.h @@ -180,6 +180,9 @@ struct config_location_options { .respect_includes_opt = -1, \ } +int location_options_set_scope(struct config_location_options *opts, + enum config_scope scope); + void location_options_init(struct repository *repo, struct config_location_options *opts, const char *prefix); diff --git a/t/t1312-config-batch.sh b/t/t1312-config-batch.sh index f7a74ddc2cb94b..40f6f90ef20f95 100755 --- a/t/t1312-config-batch.sh +++ b/t/t1312-config-batch.sh @@ -47,9 +47,10 @@ test_expect_success 'help command' ' echo "help 1" >in && cat >expect <<-\EOF && - help 1 count 2 + help 1 count 3 help 1 help 1 help 1 get 1 + help 1 set 1 EOF git config-batch >out expect <<-\EOF && - 4:help 1:1 5:count 1:2 + 4:help 1:1 5:count 1:3 4:help 1:1 4:help 1:1 4:help 1:1 3:get 1:1 + 4:help 1:1 3:set 1:1 15:unknown_command EOF @@ -205,4 +207,92 @@ test_expect_success 'get config with -z' ' test_cmp expect out ' +test_expect_success 'set config by scope' ' + test_when_finished git config remove-section test.set && + GIT_CONFIG_SYSTEM=system-config-file && + GIT_CONFIG_NOSYSTEM=0 && + GIT_CONFIG_GLOBAL=global-config-file && + export GIT_CONFIG_SYSTEM && + export GIT_CONFIG_NOSYSTEM && + export GIT_CONFIG_GLOBAL && + + cat >in <<-\EOF && + set 1 system test.set.system system + set 1 global test.set.global global + set 1 local test.set.local local with spaces + set 1 worktree test.set.worktree worktree + set 1 submodule test.set.submodule submodule + set 1 command test.set.command command + set 1 inherited test.set.inherited inherited + EOF + + cat >expect <<-\EOF && + set 1 success system test.set.system system + set 1 success global test.set.global global + set 1 success local test.set.local local with spaces + set 1 success worktree test.set.worktree worktree + command_parse_error set + command_parse_error set + command_parse_error set + EOF + + git config-batch out 2>err && + + test_must_be_empty err && + test_cmp expect out && + + cat >expect-values <<-EOF && + file:system-config-file system + file:global-config-file global + file:.git/config local with spaces + file:.git/config.worktree worktree + EOF + + git config get --show-origin --regexp --all test.set.* >values && + test_cmp expect-values values +' + +test_expect_success 'set config by scope with -z' ' + test_when_finished git config remove-section test.set && + GIT_CONFIG_SYSTEM=system-config-file && + GIT_CONFIG_NOSYSTEM=0 && + GIT_CONFIG_GLOBAL=global-config-file && + export GIT_CONFIG_SYSTEM && + export GIT_CONFIG_NOSYSTEM && + export GIT_CONFIG_GLOBAL && + + cat >in <<-\EOF && + 3:set NUL 1:1 NUL 6:system NUL 15:test.set.system NUL 6:system NUL NUL + 3:set NUL 1:1 NUL 6:global NUL 15:test.set.global NUL 6:global NUL NUL + 3:set NUL 1:1 NUL 5:local NUL 14:test.set.local NUL 17:local with spaces NUL NUL + 3:set NUL 1:1 NUL 8:worktree NUL 17:test.set.worktree NUL 8:worktree NUL NUL + 3:set NUL 1:1 NUL 9:submodule NUL 18:test.set.submodule NUL 9:submodule NUL NUL + 3:set NUL 1:1 NUL 7:command NUL 16:test.set.command NUL 7:command NUL NUL + 3:set NUL 1:1 NUL 9:inherited NUL 18:test.set.inherited NUL 9:inherited NUL NUL + EOF + + cat >expect <<-\EOF && + 3:set 1:1 7:success 6:system 15:test.set.system 6:system + 3:set 1:1 7:success 6:global 15:test.set.global 6:global + 3:set 1:1 7:success 5:local 14:test.set.local 17:local with spaces + 3:set 1:1 7:success 8:worktree 17:test.set.worktree 8:worktree + 19:command_parse_error 3:set + 19:command_parse_error 3:set + 19:command_parse_error 3:set + EOF + + test_zformat git config-batch -z >out expect-values <<-EOF && + file:system-config-file system + file:global-config-file global + file:.git/config local with spaces + file:.git/config.worktree worktree + EOF + + git config get --show-origin --regexp --all test.set.* >values && + test_cmp expect-values values +' + test_done From cf4f054fb6d382875402511b49ee901486380476 Mon Sep 17 00:00:00 2001 From: Derrick Stolee Date: Sun, 25 Jan 2026 11:17:56 -0500 Subject: [PATCH 10/11] t1312: create read/write test This new test will be extended in the future to ensure that multiple commands that execute in order update the configuration state enough to reflect new written values as we read them in later commands. Signed-off-by: Derrick Stolee --- t/t1312-config-batch.sh | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/t/t1312-config-batch.sh b/t/t1312-config-batch.sh index 40f6f90ef20f95..11380f4247716a 100755 --- a/t/t1312-config-batch.sh +++ b/t/t1312-config-batch.sh @@ -295,4 +295,31 @@ test_expect_success 'set config by scope with -z' ' test_cmp expect-values values ' +test_expect_success 'read/write interactions in sequence' ' + test_when_finished git config remove-section test.rw && + + cat >in <<-\EOF && + get 1 local test.rw.missing + set 1 local test.rw.found found + get 1 local test.rw.found + set 1 local test.rw.found updated + get 1 local test.rw.found + EOF + + cat >expect <<-\EOF && + get 1 missing test.rw.missing + set 1 success local test.rw.found found + get 1 found test.rw.found local found + set 1 success local test.rw.found updated + get 1 found test.rw.found local updated + EOF + + git config-batch out 2>err && + + test_must_be_empty err && + test_cmp expect out && + + test_cmp_config updated test.rw.found +' + test_done From 59d19fee5f5bd34c5864bebb8243afdc6bc9ea7a Mon Sep 17 00:00:00 2001 From: Derrick Stolee Date: Mon, 2 Feb 2026 09:03:41 -0500 Subject: [PATCH 11/11] config-batch: add unset v1 command Add a new 'unset' command with version 1 that mimics 'git config --unset' with optional regex pattern or '--fixed-value' arguments. Signed-off-by: Derrick Stolee --- Documentation/git-config-batch.adoc | 28 ++++++++ builtin/config-batch.c | 99 +++++++++++++++++++++++++++++ t/t1312-config-batch.sh | 61 ++++++++++++++++-- 3 files changed, 181 insertions(+), 7 deletions(-) diff --git a/Documentation/git-config-batch.adoc b/Documentation/git-config-batch.adoc index feec85c4ef8fb4..bdfd872d65d02f 100644 --- a/Documentation/git-config-batch.adoc +++ b/Documentation/git-config-batch.adoc @@ -135,6 +135,34 @@ set 1 success set 1 failed ------------ +`unset` version 1:: + The `unset` command removes a single value from a config file. + It specifies which file by a `` parameter from among + `system`, `global`, `local`, and `worktree`. The `` is the + next positional argument. There could be two additional + arguments used to match specific config values, where the first + is either `arg:regex` or `arg:fixed-value` to specify the type + of match. ++ +------------ +unset 1 +unset 1 arg:regex +unset 1 arg:fixed-value +------------ ++ +These uses will match the behavior of `git config --unset -- ` +with the additional arguments of `` if `arg:regex` is +given or `--fixed-value ` if `arg:fixed-value` is given. ++ +The response of these commands will include a `success` message +if matched values are found and removed as expected or `failed` if an +unexpected failure occurs: ++ +------------ +unset 1 success +unset 1 failed +------------ + NUL-Terminated Format ~~~~~~~~~~~~~~~~~~~~~ diff --git a/builtin/config-batch.c b/builtin/config-batch.c index 373b0cad47e8d5..25a942ba613590 100644 --- a/builtin/config-batch.c +++ b/builtin/config-batch.c @@ -17,6 +17,7 @@ static int zformat = 0; #define HELP_COMMAND "help" #define GET_COMMAND "get" #define SET_COMMAND "set" +#define UNSET_COMMAND "unset" #define COMMAND_PARSE_ERROR "command_parse_error" static void print_word(const char *word, int start) @@ -445,6 +446,99 @@ static int set_command_1(struct repository *repo, return res; } +/** + * 'unset' command, version 1. + * + * Positional arguments should be of the form: + * + * [0] scope ("system", "global", "local", or "worktree") + * [1] config key + * [2] config value + * [3*] match ("regex", "fixed-value") + * [4*] value regex OR value string + * + * [N*] indicates optional parameters that are not needed. + */ +static int unset_command_1(struct repository *repo, + const char *prefix, + char *data, + size_t data_len) +{ + int res = 0, err = 0, flags = 0; + enum config_scope scope = CONFIG_SCOPE_UNKNOWN; + char *token = NULL, *key = NULL, *value_pattern = NULL; + size_t token_len; + struct config_location_options locopts = CONFIG_LOCATION_OPTIONS_INIT; + + if (!parse_token(&data, &data_len, &token, &err) || err) + goto parse_error; + + if (parse_scope(token, &scope) || + scope == CONFIG_SCOPE_UNKNOWN || + scope == CONFIG_SCOPE_SUBMODULE || + scope == CONFIG_SCOPE_COMMAND) + goto parse_error; + + if (!parse_token(&data, &data_len, &key, &err) || err) + goto parse_error; + + token_len = parse_token(&data, &data_len, &token, &err); + if (err) + goto parse_error; + + if (token_len && !strncmp(token, "arg:", 4)) { + if (!strcmp(token + 4, "fixed-value")) + flags |= CONFIG_FLAGS_FIXED_VALUE; + /* no special logic for arg:regex. */ + else if (strcmp(token + 4, "regex")) + goto parse_error; /* unknown arg. */ + + /* Use the remaining data as the value string. */ + if (!zformat) + value_pattern = data; + else { + parse_token(&data, &data_len, &value_pattern, &err); + if (err) + goto parse_error; + } + } else if (token_len) { + /* + * If we have remaining tokens not starting in "arg:", + * then we don't understand them. + */ + goto parse_error; + } + + if (location_options_set_scope(&locopts, scope)) + goto parse_error; + location_options_init(repo, &locopts, prefix); + + res = repo_config_set_multivar_in_file_gently( + repo, + locopts.source.file, + key, + /* value */ NULL, + value_pattern, + /* comment */ NULL, + flags); + + if (res) + res = emit_response(UNSET_COMMAND, "1", "failure", + scope_str(scope), key, NULL); + else + res = emit_response(UNSET_COMMAND, "1", "success", + scope_str(scope), key, NULL); + + goto cleanup; + +parse_error: + res = command_parse_error(UNSET_COMMAND); + +cleanup: + location_options_release(&locopts); + return res; +} + struct command { const char *name; command_fn fn; @@ -467,6 +561,11 @@ static struct command commands[] = { .fn = set_command_1, .version = 1, }, + { + .name = UNSET_COMMAND, + .fn = unset_command_1, + .version = 1, + }, /* unknown_command must be last. */ { .name = "", diff --git a/t/t1312-config-batch.sh b/t/t1312-config-batch.sh index 11380f4247716a..3bddbc0de3f922 100755 --- a/t/t1312-config-batch.sh +++ b/t/t1312-config-batch.sh @@ -47,10 +47,11 @@ test_expect_success 'help command' ' echo "help 1" >in && cat >expect <<-\EOF && - help 1 count 3 + help 1 count 4 help 1 help 1 help 1 get 1 help 1 set 1 + help 1 unset 1 EOF git config-batch >out expect <<-\EOF && - 4:help 1:1 5:count 1:3 + 4:help 1:1 5:count 1:4 4:help 1:1 4:help 1:1 4:help 1:1 3:get 1:1 4:help 1:1 3:set 1:1 + 4:help 1:1 5:unset 1:1 15:unknown_command EOF @@ -295,15 +297,60 @@ test_expect_success 'set config by scope with -z' ' test_cmp expect-values values ' -test_expect_success 'read/write interactions in sequence' ' - test_when_finished git config remove-section test.rw && +test_expect_success 'unset config by scope and filter' ' + GIT_CONFIG_SYSTEM=system-config-file && + GIT_CONFIG_NOSYSTEM=0 && + GIT_CONFIG_GLOBAL=global-config-file && + export GIT_CONFIG_SYSTEM && + export GIT_CONFIG_NOSYSTEM && + export GIT_CONFIG_GLOBAL && + + cat >in <<-\EOF && + set 1 system test.unset.key system + set 1 global test.unset.key global + set 1 local test.unset.key local with spaces + set 1 worktree test.unset.key worktree + unset 1 system test.unset.key + unset 1 global test.unset.key arg:regex g.* + unset 1 local test.unset.key arg:fixed-value local with spaces + unset 1 worktree test.unset.key arg:fixed-value submodule + unset 1 worktree test.unset.key arg:regex l.* + EOF + + cat >expect <<-\EOF && + set 1 success system test.unset.key system + set 1 success global test.unset.key global + set 1 success local test.unset.key local with spaces + set 1 success worktree test.unset.key worktree + unset 1 success system test.unset.key + unset 1 success global test.unset.key + unset 1 success local test.unset.key + unset 1 failure worktree test.unset.key + unset 1 failure worktree test.unset.key + EOF + + git config-batch out 2>err && + test_must_be_empty err && + test_cmp expect out && + + cat >expect-values <<-EOF && + file:.git/config.worktree worktree + EOF + + git config get --show-origin --regexp --all test.unset.key >values && + test_cmp expect-values values +' + +test_expect_success 'read/write interactions in sequence' ' cat >in <<-\EOF && get 1 local test.rw.missing set 1 local test.rw.found found get 1 local test.rw.found set 1 local test.rw.found updated get 1 local test.rw.found + unset 1 local test.rw.found arg:fixed-value updated + get 1 local test.rw.found EOF cat >expect <<-\EOF && @@ -312,14 +359,14 @@ test_expect_success 'read/write interactions in sequence' ' get 1 found test.rw.found local found set 1 success local test.rw.found updated get 1 found test.rw.found local updated + unset 1 success local test.rw.found + get 1 missing test.rw.found EOF git config-batch out 2>err && test_must_be_empty err && - test_cmp expect out && - - test_cmp_config updated test.rw.found + test_cmp expect out ' test_done