diff --git a/Makefile b/Makefile index 0976a69b4c..a757ed3963 100644 --- a/Makefile +++ b/Makefile @@ -1098,6 +1098,7 @@ LIB_OBJS += archive-tar.o LIB_OBJS += archive-zip.o LIB_OBJS += archive.o LIB_OBJS += attr.o +LIB_OBJS += autocorrect.o LIB_OBJS += base85.o LIB_OBJS += bisect.o LIB_OBJS += blame.o diff --git a/autocorrect.c b/autocorrect.c new file mode 100644 index 0000000000..b2ee9f51e8 --- /dev/null +++ b/autocorrect.c @@ -0,0 +1,89 @@ +#define USE_THE_REPOSITORY_VARIABLE + +#include "git-compat-util.h" +#include "autocorrect.h" +#include "config.h" +#include "parse.h" +#include "strbuf.h" +#include "prompt.h" +#include "gettext.h" + +static enum autocorrect_mode parse_autocorrect(const char *value) +{ + switch (git_parse_maybe_bool_text(value)) { + case 1: + return AUTOCORRECT_IMMEDIATELY; + case 0: + return AUTOCORRECT_HINT; + default: /* other random text */ + break; + } + + if (!strcmp(value, "prompt")) + return AUTOCORRECT_PROMPT; + else if (!strcmp(value, "never")) + return AUTOCORRECT_NEVER; + else if (!strcmp(value, "immediate")) + return AUTOCORRECT_IMMEDIATELY; + else if (!strcmp(value, "show")) + return AUTOCORRECT_HINT; + else + return AUTOCORRECT_DELAY; +} + +static int resolve_autocorrect(const char *var, const char *value, + const struct config_context *ctx, void *data) +{ + struct autocorrect *conf = data; + + if (strcmp(var, "help.autocorrect")) + return 0; + + conf->mode = parse_autocorrect(value); + + /* + * Disable autocorrection prompt in a non-interactive session + */ + if (conf->mode == AUTOCORRECT_PROMPT && (!isatty(0) || !isatty(2))) + conf->mode = AUTOCORRECT_NEVER; + + if (conf->mode == AUTOCORRECT_DELAY) { + conf->delay = git_config_int(var, value, ctx->kvi); + + if (!conf->delay) + conf->mode = AUTOCORRECT_HINT; + else if (conf->delay < 0 || conf->delay == 1) + conf->mode = AUTOCORRECT_IMMEDIATELY; + } + + return 0; +} + +void autocorrect_resolve(struct autocorrect *conf) +{ + read_early_config(the_repository, resolve_autocorrect, conf); +} + +void autocorrect_confirm(struct autocorrect *conf, const char *assumed) +{ + if (conf->mode == AUTOCORRECT_IMMEDIATELY) { + fprintf_ln(stderr, + _("Continuing under the assumption that you meant '%s'."), + assumed); + } else if (conf->mode == AUTOCORRECT_PROMPT) { + char *answer; + struct strbuf msg = STRBUF_INIT; + + strbuf_addf(&msg, _("Run '%s' instead [y/N]? "), assumed); + answer = git_prompt(msg.buf, PROMPT_ECHO); + strbuf_release(&msg); + + if (!(starts_with(answer, "y") || starts_with(answer, "Y"))) + exit(1); + } else if (conf->mode == AUTOCORRECT_DELAY) { + fprintf_ln(stderr, + _("Continuing in %0.1f seconds, assuming that you meant '%s'."), + conf->delay / 10.0, assumed); + sleep_millisec(conf->delay * 100); + } +} diff --git a/autocorrect.h b/autocorrect.h new file mode 100644 index 0000000000..5bb67cf6de --- /dev/null +++ b/autocorrect.h @@ -0,0 +1,36 @@ +#ifndef AUTOCORRECT_H +#define AUTOCORRECT_H + +/* An empirically derived magic number */ +#define AUTOCORRECT_SIMILARITY_FLOOR 7 +#define AUTOCORRECT_SIMILAR_ENOUGH(x) ((x) < AUTOCORRECT_SIMILARITY_FLOOR) + +enum autocorrect_mode { + AUTOCORRECT_HINT, + AUTOCORRECT_NEVER, + AUTOCORRECT_PROMPT, + AUTOCORRECT_IMMEDIATELY, + AUTOCORRECT_DELAY, +}; + +/** + * `mode` indicates which action will be performed by autocorrect_confirm(). + * `delay` is the timeout before autocorrect_confirm() returns, in tenths of a + * second. Use it only with AUTOCORRECT_DELAY. + */ +struct autocorrect { + enum autocorrect_mode mode; + int delay; +}; + +/** + * Resolve the autocorrect configuration into `conf`. + */ +void autocorrect_resolve(struct autocorrect *conf); + +/** + * Interact with the user in different ways depending on `conf->mode`. + */ +void autocorrect_confirm(struct autocorrect *conf, const char *assumed); + +#endif /* AUTOCORRECT_H */ diff --git a/builtin/notes.c b/builtin/notes.c index 9af602bdd7..f9bf350df4 100644 --- a/builtin/notes.c +++ b/builtin/notes.c @@ -1149,14 +1149,10 @@ int cmd_notes(int argc, repo_config(the_repository, git_default_config, NULL); argc = parse_options(argc, argv, prefix, options, git_notes_usage, - PARSE_OPT_SUBCOMMAND_OPTIONAL); - if (!fn) { - if (argc) { - error(_("unknown subcommand: `%s'"), argv[0]); - usage_with_options(git_notes_usage, options); - } + PARSE_OPT_SUBCOMMAND_OPTIONAL | + PARSE_OPT_SUBCOMMAND_AUTOCORRECT); + if (!fn) fn = list; - } if (override_notes_ref) { struct strbuf sb = STRBUF_INIT; diff --git a/builtin/remote.c b/builtin/remote.c index de989ea3ba..6a78ab8f4c 100644 --- a/builtin/remote.c +++ b/builtin/remote.c @@ -1953,15 +1953,11 @@ int cmd_remote(int argc, }; argc = parse_options(argc, argv, prefix, options, builtin_remote_usage, - PARSE_OPT_SUBCOMMAND_OPTIONAL); + PARSE_OPT_SUBCOMMAND_OPTIONAL | + PARSE_OPT_SUBCOMMAND_AUTOCORRECT); - if (fn) { + if (fn) return !!fn(argc, argv, prefix, repo); - } else { - if (argc) { - error(_("unknown subcommand: `%s'"), argv[0]); - usage_with_options(builtin_remote_usage, options); - } + else return !!show_all(); - } } diff --git a/help.c b/help.c index 46241492ce..ab2458cb5c 100644 --- a/help.c +++ b/help.c @@ -22,6 +22,7 @@ #include "repository.h" #include "alias.h" #include "utf8.h" +#include "autocorrect.h" #ifndef NO_CURL #include "git-curl-compat.h" /* For LIBCURL_VERSION only */ @@ -536,60 +537,13 @@ int is_in_cmdlist(struct cmdnames *c, const char *s) return 0; } -struct help_unknown_cmd_config { - int autocorrect; - struct cmdnames aliases; -}; - -#define AUTOCORRECT_SHOW (-4) -#define AUTOCORRECT_PROMPT (-3) -#define AUTOCORRECT_NEVER (-2) -#define AUTOCORRECT_IMMEDIATELY (-1) - -static int parse_autocorrect(const char *value) +static int resolve_aliases(const char *var, const char *value UNUSED, + const struct config_context *ctx UNUSED, void *data) { - switch (git_parse_maybe_bool_text(value)) { - case 1: - return AUTOCORRECT_IMMEDIATELY; - case 0: - return AUTOCORRECT_SHOW; - default: /* other random text */ - break; - } - - if (!strcmp(value, "prompt")) - return AUTOCORRECT_PROMPT; - if (!strcmp(value, "never")) - return AUTOCORRECT_NEVER; - if (!strcmp(value, "immediate")) - return AUTOCORRECT_IMMEDIATELY; - if (!strcmp(value, "show")) - return AUTOCORRECT_SHOW; - - return 0; -} - -static int git_unknown_cmd_config(const char *var, const char *value, - const struct config_context *ctx, - void *cb) -{ - struct help_unknown_cmd_config *cfg = cb; + struct cmdnames *aliases = data; const char *subsection, *key; size_t subsection_len; - if (!strcmp(var, "help.autocorrect")) { - int v = parse_autocorrect(value); - - if (!v) { - v = git_config_int(var, value, ctx->kvi); - if (v < 0 || v == 1) - v = AUTOCORRECT_IMMEDIATELY; - } - - cfg->autocorrect = v; - } - - /* Also use aliases for command lookup */ if (!parse_config_key(var, "alias", &subsection, &subsection_len, &key)) { size_t key_len = strlen(key); @@ -597,16 +551,16 @@ static int git_unknown_cmd_config(const char *var, const char *value, if (subsection) { /* [alias "name"] command = value */ if (!strcmp(key, "command")) - add_cmdname(&cfg->aliases, subsection, + add_cmdname(aliases, subsection, subsection_len); else { key = var + strlen("alias."); key_len = strlen(key); - add_cmdname(&cfg->aliases, key, key_len); + add_cmdname(aliases, key, key_len); } } else { /* alias.name = value */ - add_cmdname(&cfg->aliases, key, key_len); + add_cmdname(aliases, key, key_len); } } @@ -633,38 +587,32 @@ static void add_cmd_list(struct cmdnames *cmds, struct cmdnames *old) old->cnt = 0; } -/* An empirically derived magic number */ -#define SIMILARITY_FLOOR 7 -#define SIMILAR_ENOUGH(x) ((x) < SIMILARITY_FLOOR) - static const char bad_interpreter_advice[] = N_("'%s' appears to be a git command, but we were not\n" "able to execute it. Maybe git-%s is broken?"); char *help_unknown_cmd(const char *cmd) { - struct help_unknown_cmd_config cfg = { 0 }; + struct cmdnames aliases = { 0 }; + struct autocorrect autocorrect = { 0 }; int i, n, best_similarity = 0; struct cmdnames main_cmds = { 0 }; struct cmdnames other_cmds = { 0 }; struct cmdname_help *common_cmds; - read_early_config(the_repository, git_unknown_cmd_config, &cfg); + autocorrect_resolve(&autocorrect); - /* - * Disable autocorrection prompt in a non-interactive session - */ - if ((cfg.autocorrect == AUTOCORRECT_PROMPT) && (!isatty(0) || !isatty(2))) - cfg.autocorrect = AUTOCORRECT_NEVER; - - if (cfg.autocorrect == AUTOCORRECT_NEVER) { + if (autocorrect.mode == AUTOCORRECT_NEVER) { fprintf_ln(stderr, _("git: '%s' is not a git command. See 'git --help'."), cmd); exit(1); } load_command_list("git-", &main_cmds, &other_cmds); - add_cmd_list(&main_cmds, &cfg.aliases); + /* Also use aliases for command lookup */ + read_early_config(the_repository, resolve_aliases, &aliases); + + add_cmd_list(&main_cmds, &aliases); add_cmd_list(&main_cmds, &other_cmds); QSORT(main_cmds.names, main_cmds.cnt, cmdname_compare); uniq(&main_cmds); @@ -714,7 +662,7 @@ char *help_unknown_cmd(const char *cmd) if (main_cmds.cnt <= n) { /* prefix matches with everything? that is too ambiguous */ - best_similarity = SIMILARITY_FLOOR + 1; + best_similarity = AUTOCORRECT_SIMILARITY_FLOOR + 1; } else { /* count all the most similar ones */ for (best_similarity = main_cmds.names[n++]->len; @@ -723,37 +671,18 @@ char *help_unknown_cmd(const char *cmd) n++) ; /* still counting */ } - if (cfg.autocorrect && cfg.autocorrect != AUTOCORRECT_SHOW && n == 1 && - SIMILAR_ENOUGH(best_similarity)) { + + if (autocorrect.mode != AUTOCORRECT_HINT && n == 1 && + AUTOCORRECT_SIMILAR_ENOUGH(best_similarity)) { char *assumed = xstrdup(main_cmds.names[0]->name); fprintf_ln(stderr, - _("WARNING: You called a Git command named '%s', " - "which does not exist."), + _("WARNING: You called a Git command named '%s', which does not exist."), cmd); - if (cfg.autocorrect == AUTOCORRECT_IMMEDIATELY) - fprintf_ln(stderr, - _("Continuing under the assumption that " - "you meant '%s'."), - assumed); - else if (cfg.autocorrect == AUTOCORRECT_PROMPT) { - char *answer; - struct strbuf msg = STRBUF_INIT; - strbuf_addf(&msg, _("Run '%s' instead [y/N]? "), assumed); - answer = git_prompt(msg.buf, PROMPT_ECHO); - strbuf_release(&msg); - if (!(starts_with(answer, "y") || - starts_with(answer, "Y"))) - exit(1); - } else { - fprintf_ln(stderr, - _("Continuing in %0.1f seconds, " - "assuming that you meant '%s'."), - (float)cfg.autocorrect/10.0, assumed); - sleep_millisec(cfg.autocorrect * 100); - } - cmdnames_release(&cfg.aliases); + autocorrect_confirm(&autocorrect, assumed); + + cmdnames_release(&aliases); cmdnames_release(&main_cmds); cmdnames_release(&other_cmds); return assumed; @@ -761,11 +690,10 @@ char *help_unknown_cmd(const char *cmd) fprintf_ln(stderr, _("git: '%s' is not a git command. See 'git --help'."), cmd); - if (SIMILAR_ENOUGH(best_similarity)) { + if (AUTOCORRECT_SIMILAR_ENOUGH(best_similarity)) { fprintf_ln(stderr, Q_("\nThe most similar command is", - "\nThe most similar commands are", - n)); + "\nThe most similar commands are", n)); for (i = 0; i < n; i++) fprintf(stderr, "\t%s\n", main_cmds.names[i]->name); diff --git a/meson.build b/meson.build index 3247697f74..0947904eec 100644 --- a/meson.build +++ b/meson.build @@ -290,6 +290,7 @@ libgit_sources = [ 'archive-zip.c', 'archive.c', 'attr.c', + 'autocorrect.c', 'base85.c', 'bisect.c', 'blame.c', diff --git a/parse-options.c b/parse-options.c index f4647e0099..dfe6151904 100644 --- a/parse-options.c +++ b/parse-options.c @@ -7,6 +7,8 @@ #include "string-list.h" #include "strmap.h" #include "utf8.h" +#include "autocorrect.h" +#include "levenshtein.h" static int disallow_abbreviated_options; @@ -606,17 +608,141 @@ static enum parse_opt_result parse_nodash_opt(struct parse_opt_ctx_t *p, return PARSE_OPT_ERROR; } -static enum parse_opt_result parse_subcommand(const char *arg, - const struct option *options) +static int parse_subcommand(const char *arg, const struct option *options) { - for (; options->type != OPTION_END; options++) - if (options->type == OPTION_SUBCOMMAND && - !strcmp(options->long_name, arg)) { - *(parse_opt_subcommand_fn **)options->value = options->subcommand_fn; - return PARSE_OPT_SUBCOMMAND; - } + for (; options->type != OPTION_END; options++) { + parse_opt_subcommand_fn **opt_val; - return PARSE_OPT_UNKNOWN; + if (options->type != OPTION_SUBCOMMAND || + strcmp(options->long_name, arg)) + continue; + + opt_val = options->value; + *opt_val = options->subcommand_fn; + return 0; + } + + return -1; +} + +static void find_subcommands(struct string_list *list, + const struct option *options) +{ + for (; options->type != OPTION_END; options++) { + if (options->type == OPTION_SUBCOMMAND) + string_list_append(list, options->long_name); + } +} + +static int levenshtein_compare(const void *p1, const void *p2) +{ + const struct string_list_item *i1 = p1, *i2 = p2; + const char *s1 = i1->string, *s2 = i2->string; + int l1 = (intptr_t)i1->util; + int l2 = (intptr_t)i2->util; + + return l1 != l2 ? l1 - l2 : strcmp(s1, s2); +} + +static const char *autocorrect_subcommand(const char *cmd, + struct string_list *cmds) +{ + struct autocorrect autocorrect = { 0 }; + unsigned int n = 0; + int best = 0; + struct string_list_item *cand; + + autocorrect_resolve(&autocorrect); + + if (autocorrect.mode == AUTOCORRECT_NEVER) + return NULL; + + for_each_string_list_item(cand, cmds) { + if (starts_with(cand->string, cmd)) { + cand->util = NULL; + } else { + int edit = levenshtein(cmd, cand->string, + 0, 2, 1, 3) + 1; + + cand->util = (void *)(intptr_t)edit; + } + } + + QSORT(cmds->items, cmds->nr, levenshtein_compare); + + /* Match help.c:help_unknown_cmd */ + for (; n < cmds->nr && !cmds->items[n].util; n++); + + if (n == cmds->nr) + /* prefix matches with every subcommands */ + best = AUTOCORRECT_SIMILARITY_FLOOR + 1; + else + for (best = (intptr_t)cmds->items[n++].util; + (n < cmds->nr && best == (intptr_t)cmds->items[n].util); + n++); + + if (autocorrect.mode != AUTOCORRECT_HINT && n == 1 && + AUTOCORRECT_SIMILAR_ENOUGH(best)) { + fprintf_ln(stderr, + _("WARNING: You called a subcommand named '%s', which does not exist."), + cmd); + + autocorrect_confirm(&autocorrect, cmds->items[0].string); + return cmds->items[0].string; + } + + if (AUTOCORRECT_SIMILAR_ENOUGH(best)) { + error(_("'%s' is not a subcommand."), cmd); + + fprintf_ln(stderr, + Q_("\nThe most similar subcommand is", + "\nThe most similar subcommands are", + n)); + + for (unsigned int i = 0; i < n; i++) + fprintf(stderr, "\t%s\n", cmds->items[i].string); + + exit(129); + } + + return NULL; +} + +static enum parse_opt_result handle_subcommand(struct parse_opt_ctx_t *ctx, + const char *arg, + const struct option *options, + const char * const usagestr[]) +{ + int err; + const char *assumed; + struct string_list cmds = STRING_LIST_INIT_NODUP; + + err = parse_subcommand(arg, options); + if (!err) + return PARSE_OPT_SUBCOMMAND; + + if (ctx->flags & PARSE_OPT_SUBCOMMAND_OPTIONAL && + !(ctx->flags & PARSE_OPT_SUBCOMMAND_AUTOCORRECT)) { + /* + * arg is neither a short or long option nor a subcommand. + * Since this command has a default operation mode, we have to + * treat this arg and all remaining args as args meant to that + * default operation mode. So we are done parsing. + */ + return PARSE_OPT_DONE; + } + + find_subcommands(&cmds, options); + assumed = autocorrect_subcommand(arg, &cmds); + + if (!assumed) { + error(_("unknown subcommand: `%s'"), arg); + usage_with_options(usagestr, options); + } + + string_list_clear(&cmds, 0); + parse_subcommand(assumed, options); + return PARSE_OPT_SUBCOMMAND; } static void check_typos(const char *arg, const struct option *options) @@ -1011,38 +1137,16 @@ enum parse_opt_result parse_options_step(struct parse_opt_ctx_t *ctx, if (*arg != '-' || !arg[1]) { if (parse_nodash_opt(ctx, arg, options) == 0) continue; - if (!ctx->has_subcommands) { - if (ctx->flags & PARSE_OPT_STOP_AT_NON_OPTION) - return PARSE_OPT_NON_OPTION; - ctx->out[ctx->cpidx++] = ctx->argv[0]; - continue; - } - switch (parse_subcommand(arg, options)) { - case PARSE_OPT_SUBCOMMAND: - return PARSE_OPT_SUBCOMMAND; - case PARSE_OPT_UNKNOWN: - if (ctx->flags & PARSE_OPT_SUBCOMMAND_OPTIONAL) - /* - * arg is neither a short or long - * option nor a subcommand. Since - * this command has a default - * operation mode, we have to treat - * this arg and all remaining args - * as args meant to that default - * operation mode. - * So we are done parsing. - */ - return PARSE_OPT_DONE; - error(_("unknown subcommand: `%s'"), arg); - usage_with_options(usagestr, options); - case PARSE_OPT_COMPLETE: - case PARSE_OPT_HELP: - case PARSE_OPT_ERROR: - case PARSE_OPT_DONE: - case PARSE_OPT_NON_OPTION: - /* Impossible. */ - BUG("parse_subcommand() cannot return these"); - } + + if (ctx->has_subcommands) + return handle_subcommand(ctx, arg, options, + usagestr); + + if (ctx->flags & PARSE_OPT_STOP_AT_NON_OPTION) + return PARSE_OPT_NON_OPTION; + + ctx->out[ctx->cpidx++] = ctx->argv[0]; + continue; } /* lone -h asks for help */ diff --git a/parse-options.h b/parse-options.h index 0d1f738f8d..ba97505aa7 100644 --- a/parse-options.h +++ b/parse-options.h @@ -40,6 +40,7 @@ enum parse_opt_flags { PARSE_OPT_ONE_SHOT = 1 << 5, PARSE_OPT_SHELL_EVAL = 1 << 6, PARSE_OPT_SUBCOMMAND_OPTIONAL = 1 << 7, + PARSE_OPT_SUBCOMMAND_AUTOCORRECT = 1 << 8, }; enum parse_opt_option_flags { diff --git a/t/meson.build b/t/meson.build index 1bbb668231..4a42c7c200 100644 --- a/t/meson.build +++ b/t/meson.build @@ -985,6 +985,7 @@ integration_tests = [ 't9001-send-email.sh', 't9002-column.sh', 't9003-help-autocorrect.sh', + 't9004-autocorrect-subcommand.sh', 't9100-git-svn-basic.sh', 't9101-git-svn-props.sh', 't9102-git-svn-deep-rmdir.sh', diff --git a/t/t0040-parse-options.sh b/t/t0040-parse-options.sh index ca55ea8228..2a2fef1e17 100755 --- a/t/t0040-parse-options.sh +++ b/t/t0040-parse-options.sh @@ -632,8 +632,9 @@ test_expect_success 'subcommand - unknown subcommand shows error and usage' ' test_expect_success 'subcommand - subcommands cannot be abbreviated' ' test_expect_code 129 test-tool parse-subcommand cmd subcmd-o 2>err && - grep "^error: unknown subcommand: \`subcmd-o$SQ$" err && - grep ^usage: err + grep "^The most similar subcommands are$" err && + grep "subcmd-one$" err && + grep "subcmd-two$" err ' test_expect_success 'subcommand - no negated subcommands' ' diff --git a/t/t7900-maintenance.sh b/t/t7900-maintenance.sh index d7f82e1bec..97f9a5fa92 100755 --- a/t/t7900-maintenance.sh +++ b/t/t7900-maintenance.sh @@ -37,8 +37,8 @@ test_systemd_analyze_verify () { test_expect_success 'help text' ' test_expect_code 129 git maintenance -h >actual && test_grep "usage: git maintenance " actual && - test_expect_code 129 git maintenance barf 2>err && - test_grep "unknown subcommand: \`barf'\''" err && + test_expect_code 129 git maintenance abarf 2>err && + test_grep "unknown subcommand: \`abarf'\''" err && test_grep "usage: git maintenance" err && test_expect_code 129 git maintenance 2>err && test_grep "error: need a subcommand" err && diff --git a/t/t9004-autocorrect-subcommand.sh b/t/t9004-autocorrect-subcommand.sh new file mode 100755 index 0000000000..09d281bd10 --- /dev/null +++ b/t/t9004-autocorrect-subcommand.sh @@ -0,0 +1,58 @@ +#!/bin/sh + +test_description='subcommand auto-correction test + +Test autocorrection for subcommands with different +help.autocorrect mode.' + +. ./test-lib.sh + +test_expect_success 'setup' " + echo '^error: unknown subcommand: ' >grep_unknown +" + +test_expect_success 'default is show hint only' ' + test_must_fail git worktree lsit 2>actual && + test_grep "most similar subcommand" actual +' + +test_expect_success "'never' disables autocorrection" ' + test_config help.autocorrect never && + + test_must_fail git worktree lsit 2>actual && + head -n1 actual >first && test_grep -f grep_unknown first +' + +for mode in false no off 0 show +do + test_expect_success "'$mode' disables autocorrection and shows hints" " + test_config help.autocorrect $mode && + + test_must_fail git worktree lsit 2>actual && + test_grep 'most similar subcommand' actual + " +done + +for mode in -39 immediate 1 +do + test_expect_success "autocorrect immediately with '$mode'" - <<-EOT + test_config help.autocorrect $mode && + + git worktree lsit 2>actual && + test_grep "you meant 'list'\.$" actual + EOT +done + +test_expect_success 'delay path is executed' - <<-\EOT + test_config help.autocorrect 2 && + + git worktree lsit 2>actual && + test_grep '^Continuing in 0.2 seconds, ' actual +EOT + +test_expect_success 'deny if too dissimilar' - <<-\EOT + test_must_fail git remote rensnr 2>actual && + head -n1 actual >first && test_grep -f grep_unknown first +EOT + +test_done