From db523f8e98eb425a8d428b3c363af6fd40fbc342 Mon Sep 17 00:00:00 2001 From: "rajath.k" Date: Sat, 1 Nov 2025 23:02:19 +0530 Subject: [PATCH] checkout: add fuzzy branch name suggestions When 'git checkout ' fails because the branch doesn't exist, suggest similar branch names using Levenshtein distance matching, similar to how Git suggests similar commands for typos. This helps users in large teams where remembering exact branch names can be difficult, providing a Unix tab-completion-like experience. The feature: - Shows up to 5 similar branch names when checkout fails - Uses the same Levenshtein distance algorithm as Git's help system - Only suggests branches that are similar enough (distance < 7) - Works for both 'git checkout' and 'git switch' commands - Handles both invalid reference errors and pathspec errors - Avoids suggestions for file paths (containing '/') Includes comprehensive test coverage for the new functionality. Signed-off-by: rajath.k --- Makefile | 1 + branch-suggestions.c | 99 ++++++++++++++++++++++++++ branch-suggestions.h | 11 +++ builtin/checkout.c | 13 +++- meson.build | 1 + t/t2028-checkout-branch-suggestions.sh | 37 ++++++++++ 6 files changed, 160 insertions(+), 2 deletions(-) create mode 100644 branch-suggestions.c create mode 100644 branch-suggestions.h create mode 100755 t/t2028-checkout-branch-suggestions.sh 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