diff --git a/Makefile b/Makefile index 7e0f77e2988e3b..09a3e34fdff534 100644 --- a/Makefile +++ b/Makefile @@ -1091,6 +1091,7 @@ LIB_OBJS += blame.o LIB_OBJS += blob.o LIB_OBJS += bloom.o LIB_OBJS += branch.o +LIB_OBJS += branch-suggestions.o LIB_OBJS += bundle-uri.o LIB_OBJS += bundle.o LIB_OBJS += cache-tree.o diff --git a/branch-suggestions.c b/branch-suggestions.c new file mode 100644 index 00000000000000..50294c2c212d11 --- /dev/null +++ b/branch-suggestions.c @@ -0,0 +1,99 @@ +#define USE_THE_REPOSITORY_VARIABLE + +#include "git-compat-util.h" +#include "branch-suggestions.h" +#include "refs.h" +#include "string-list.h" +#include "levenshtein.h" +#include "gettext.h" +#include "repository.h" +#include "strbuf.h" + +#define SIMILARITY_FLOOR 7 +#define SIMILAR_ENOUGH(x) ((x) < SIMILARITY_FLOOR) +#define MAX_SUGGESTIONS 5 + +struct branch_suggestion_cb { + const char *attempted_name; + struct string_list *suggestions; +}; + +static int collect_branch_cb(const char *refname, const char *referent UNUSED, + const struct object_id *oid UNUSED, + int flags UNUSED, void *cb_data) +{ + struct branch_suggestion_cb *cb = cb_data; + const char *branch_name; + + /* Since we're using refs_for_each_ref_in with "refs/heads/", + * the refname might already be stripped or might still have the prefix */ + if (starts_with(refname, "refs/heads/")) { + branch_name = refname + strlen("refs/heads/"); + } else { + branch_name = refname; + } + + /* Skip the attempted name itself */ + if (!strcmp(branch_name, cb->attempted_name)) + return 0; + + string_list_append(cb->suggestions, branch_name); + return 0; +} + +void suggest_similar_branch_names(const char *attempted_name) +{ + struct string_list branches = STRING_LIST_INIT_DUP; + struct branch_suggestion_cb cb_data; + size_t i; + int best_similarity = INT_MAX; + int suggestion_count = 0; + + cb_data.attempted_name = attempted_name; + cb_data.suggestions = &branches; + + /* Collect all local branch names */ + refs_for_each_ref_in(get_main_ref_store(the_repository), "refs/heads/", + collect_branch_cb, &cb_data); + + if (!branches.nr) + goto cleanup; + + /* Calculate Levenshtein distances */ + for (i = 0; i < branches.nr; i++) { + const char *branch_name = branches.items[i].string; + int distance; + + /* Give prefix matches a very good score */ + if (starts_with(branch_name, attempted_name)) { + distance = 0; + } else { + distance = levenshtein(attempted_name, branch_name, 0, 2, 1, 3); + } + + branches.items[i].util = (void *)(intptr_t)distance; + + if (distance < best_similarity) + best_similarity = distance; + } + + /* Only show suggestions if they're similar enough */ + if (!SIMILAR_ENOUGH(best_similarity)) + goto cleanup; + + /* Count and display similar branches */ + for (i = 0; i < branches.nr && suggestion_count < MAX_SUGGESTIONS; i++) { + int distance = (int)(intptr_t)branches.items[i].util; + + if (distance <= best_similarity && SIMILAR_ENOUGH(distance)) { + if (suggestion_count == 0) { + fprintf(stderr, "%s\n", _("hint: Did you mean one of these?")); + } + fprintf(stderr, "hint: %s\n", branches.items[i].string); + suggestion_count++; + } + } + +cleanup: + string_list_clear(&branches, 0); +} diff --git a/branch-suggestions.h b/branch-suggestions.h new file mode 100644 index 00000000000000..3dd996e5ed4132 --- /dev/null +++ b/branch-suggestions.h @@ -0,0 +1,11 @@ +#ifndef BRANCH_SUGGESTIONS_H +#define BRANCH_SUGGESTIONS_H + +/** + * Suggest similar branch names when a branch checkout fails. + * This function analyzes local branches and suggests ones that are + * similar to the attempted branch name using fuzzy matching. + */ +void suggest_similar_branch_names(const char *attempted_name); + +#endif /* BRANCH_SUGGESTIONS_H */ diff --git a/builtin/checkout.c b/builtin/checkout.c index f9453473fe2a20..0d0a70e99d535c 100644 --- a/builtin/checkout.c +++ b/builtin/checkout.c @@ -4,6 +4,7 @@ #include "builtin.h" #include "advice.h" #include "branch.h" +#include "branch-suggestions.h" #include "cache-tree.h" #include "checkout.h" #include "commit.h" @@ -605,6 +606,10 @@ static int checkout_paths(const struct checkout_opts *opts, opts); if (report_path_error(ps_matched, &opts->pathspec)) { + /* If there's only one pathspec and it looks like a branch name, suggest similar branches */ + if (opts->pathspec.nr == 1 && !strchr(opts->pathspec.items[0].original, '/')) { + suggest_similar_branch_names(opts->pathspec.items[0].original); + } free(ps_matched); return 1; } @@ -1447,8 +1452,10 @@ static int parse_branchname_arg(int argc, const char **argv, } if (!recover_with_dwim) { - if (has_dash_dash) + if (has_dash_dash) { + suggest_similar_branch_names(arg); die(_("invalid reference: %s"), arg); + } return argcount; } } @@ -1635,9 +1642,11 @@ static int checkout_branch(struct checkout_opts *opts, } else if (opts->track == BRANCH_TRACK_UNSPECIFIED) opts->track = git_branch_track; - if (new_branch_info->name && !new_branch_info->commit) + if (new_branch_info->name && !new_branch_info->commit) { + suggest_similar_branch_names(new_branch_info->name); die(_("Cannot switch branch to a non-commit '%s'"), new_branch_info->name); + } if (noop_switch && !opts->switch_branch_doing_nothing_is_ok) diff --git a/meson.build b/meson.build index 2b763f7c53493c..aaa1f42b6c9832 100644 --- a/meson.build +++ b/meson.build @@ -287,6 +287,7 @@ libgit_sources = [ 'blob.c', 'bloom.c', 'branch.c', + 'branch-suggestions.c', 'bundle-uri.c', 'bundle.c', 'cache-tree.c', diff --git a/t/t2028-checkout-branch-suggestions.sh b/t/t2028-checkout-branch-suggestions.sh new file mode 100755 index 00000000000000..108d9fa53f9e14 --- /dev/null +++ b/t/t2028-checkout-branch-suggestions.sh @@ -0,0 +1,37 @@ +#!/bin/sh + +test_description='checkout branch suggestions' + +. ./test-lib.sh + +test_expect_success 'setup' ' + test_commit initial && + git branch feature-authentication && + git branch feature-authorization && + git branch bugfix-auth-issue +' + +test_expect_success 'suggest similar branch names on checkout failure' ' + test_must_fail git checkout feature-auth 2>err && + grep "hint: Did you mean one of these?" err && + grep "feature-authentication" err && + grep "feature-authorization" err +' + +test_expect_success 'suggest single branch name on close match' ' + test_must_fail git checkout feature-authent 2>err && + grep "hint: Did you mean one of these?" err && + grep "feature-authentication" err +' + +test_expect_success 'no suggestions for very different names' ' + test_must_fail git checkout completely-different-name 2>err && + ! grep "hint: Did you mean" err +' + +test_expect_success 'no suggestions for paths with slashes' ' + test_must_fail git checkout nonexistent/file.txt 2>err && + ! grep "hint: Did you mean" err +' + +test_done \ No newline at end of file