diff --git a/include/git2/diff.h b/include/git2/diff.h index edec9957b..85727d969 100644 --- a/include/git2/diff.h +++ b/include/git2/diff.h @@ -46,6 +46,7 @@ enum { GIT_DIFF_INCLUDE_UNTRACKED = (1 << 8), GIT_DIFF_INCLUDE_UNMODIFIED = (1 << 9), GIT_DIFF_RECURSE_UNTRACKED_DIRS = (1 << 10), + GIT_DIFF_DISABLE_PATHSPEC_MATCH = (1 << 11), }; /** diff --git a/include/git2/status.h b/include/git2/status.h index 69b6e47e0..9e7b5de4a 100644 --- a/include/git2/status.h +++ b/include/git2/status.h @@ -96,6 +96,8 @@ typedef enum { * the top-level directory will be included (with a trailing * slash on the entry name). Given this flag, the directory * itself will not be included, but all the files in it will. + * - GIT_STATUS_OPT_DISABLE_PATHSPEC_MATCH indicates that the given + * path will be treated as a literal path, and not as a pathspec. */ enum { @@ -104,6 +106,7 @@ enum { GIT_STATUS_OPT_INCLUDE_UNMODIFIED = (1 << 2), GIT_STATUS_OPT_EXCLUDE_SUBMODULES = (1 << 3), GIT_STATUS_OPT_RECURSE_UNTRACKED_DIRS = (1 << 4), + GIT_STATUS_OPT_DISABLE_PATHSPEC_MATCH = (1 << 5), }; /** diff --git a/src/attr_file.c b/src/attr_file.c index 0dad09727..837c42d8e 100644 --- a/src/attr_file.c +++ b/src/attr_file.c @@ -426,17 +426,7 @@ int git_attr_fnmatch__parse( return -1; } else { /* strip '\' that might have be used for internal whitespace */ - char *to = spec->pattern; - for (scan = spec->pattern; *scan; to++, scan++) { - if (*scan == '\\') - scan++; /* skip '\' but include next char */ - if (to != scan) - *to = *scan; - } - if (to != scan) { - *to = '\0'; - spec->length = (to - spec->pattern); - } + spec->length = git__unescape(spec->pattern); } return 0; diff --git a/src/buffer.c b/src/buffer.c index 5d54ee1a5..b57998e1b 100644 --- a/src/buffer.c +++ b/src/buffer.c @@ -496,3 +496,7 @@ bool git_buf_is_binary(const git_buf *buf) return ((printable >> 7) < nonprintable); } +void git_buf_unescape(git_buf *buf) +{ + buf->size = git__unescape(buf->ptr); +} diff --git a/src/buffer.h b/src/buffer.h index 75f3b0e4f..17922e408 100644 --- a/src/buffer.h +++ b/src/buffer.h @@ -151,4 +151,7 @@ int git_buf_common_prefix(git_buf *buf, const git_strarray *strings); /* Check if buffer looks like it contains binary data */ bool git_buf_is_binary(const git_buf *buf); +/* Unescape all characters in a buffer */ +void git_buf_unescape(git_buf *buf); + #endif diff --git a/src/diff.c b/src/diff.c index 09f319e6e..2b1529d63 100644 --- a/src/diff.c +++ b/src/diff.c @@ -20,14 +20,21 @@ static char *diff_prefix_from_pathspec(const git_strarray *pathspec) return NULL; /* diff prefix will only be leading non-wildcards */ - for (scan = prefix.ptr; *scan && !git__iswildcard(*scan); ++scan); + for (scan = prefix.ptr; *scan; ++scan) { + if (git__iswildcard(*scan) && + (scan == prefix.ptr || (*(scan - 1) != '\\'))) + break; + } git_buf_truncate(&prefix, scan - prefix.ptr); - if (prefix.size > 0) - return git_buf_detach(&prefix); + if (prefix.size <= 0) { + git_buf_free(&prefix); + return NULL; + } - git_buf_free(&prefix); - return NULL; + git_buf_unescape(&prefix); + + return git_buf_detach(&prefix); } static bool diff_pathspec_is_interesting(const git_strarray *pathspec) @@ -54,7 +61,11 @@ static bool diff_path_matches_pathspec(git_diff_list *diff, const char *path) return true; git_vector_foreach(&diff->pathspec, i, match) { - int result = p_fnmatch(match->pattern, path, 0); + int result = strcmp(match->pattern, path) ? FNM_NOMATCH : 0; + + if (((diff->opts.flags & GIT_DIFF_DISABLE_PATHSPEC_MATCH) == 0) && + result == FNM_NOMATCH) + result = p_fnmatch(match->pattern, path, 0); /* if we didn't match, look for exact dirname prefix match */ if (result == FNM_NOMATCH && @@ -826,4 +837,3 @@ int git_diff_merge( return error; } - diff --git a/src/status.c b/src/status.c index e9ad3cfe4..d78237689 100644 --- a/src/status.c +++ b/src/status.c @@ -99,6 +99,8 @@ int git_status_foreach_ext( diffopt.flags = diffopt.flags | GIT_DIFF_INCLUDE_UNMODIFIED; if ((opts->flags & GIT_STATUS_OPT_RECURSE_UNTRACKED_DIRS) != 0) diffopt.flags = diffopt.flags | GIT_DIFF_RECURSE_UNTRACKED_DIRS; + if ((opts->flags & GIT_STATUS_OPT_DISABLE_PATHSPEC_MATCH) != 0) + diffopt.flags = diffopt.flags | GIT_DIFF_DISABLE_PATHSPEC_MATCH; /* TODO: support EXCLUDE_SUBMODULES flag */ if (show != GIT_STATUS_SHOW_WORKDIR_ONLY && @@ -176,10 +178,12 @@ static int get_one_status(const char *path, unsigned int status, void *data) sfi->count++; sfi->status = status; - if (sfi->count > 1 || strcmp(sfi->expected, path) != 0) { + if (sfi->count > 1 || + (strcmp(sfi->expected, path) != 0 && + p_fnmatch(sfi->expected, path, 0) != 0)) { giterr_set(GITERR_INVALID, "Ambiguous path '%s' given to git_status_file", sfi->expected); - return -1; + return GIT_EAMBIGUOUS; } return 0; diff --git a/src/util.c b/src/util.c index 3093cd767..90bb3d02a 100644 --- a/src/util.c +++ b/src/util.c @@ -435,3 +435,21 @@ int git__parse_bool(int *out, const char *value) return -1; } + +size_t git__unescape(char *str) +{ + char *scan, *pos = str; + + for (scan = str; *scan; pos++, scan++) { + if (*scan == '\\' && *(scan + 1) != '\0') + scan++; /* skip '\' but include next char */ + if (pos != scan) + *pos = *scan; + } + + if (pos != scan) { + *pos = '\0'; + } + + return (pos - str); +} diff --git a/src/util.h b/src/util.h index a84dcab1e..905fc927f 100644 --- a/src/util.h +++ b/src/util.h @@ -238,4 +238,13 @@ extern int git__parse_bool(int *out, const char *value); */ int git__date_parse(git_time_t *out, const char *date); +/* + * Unescapes a string in-place. + * + * Edge cases behavior: + * - "jackie\" -> "jacky\" + * - "chan\\" -> "chan\" + */ +extern size_t git__unescape(char *str); + #endif /* INCLUDE_util_h__ */ diff --git a/tests-clar/core/buffer.c b/tests-clar/core/buffer.c index 21aaaed7e..b6274b012 100644 --- a/tests-clar/core/buffer.c +++ b/tests-clar/core/buffer.c @@ -658,3 +658,23 @@ void test_core_buffer__puts_escaped(void) git_buf_free(&a); } + +static void assert_unescape(char *expected, char *to_unescape) { + git_buf buf = GIT_BUF_INIT; + + cl_git_pass(git_buf_sets(&buf, to_unescape)); + git_buf_unescape(&buf); + cl_assert_equal_s(expected, buf.ptr); + cl_assert_equal_i(strlen(expected), buf.size); + + git_buf_free(&buf); +} + +void test_core_buffer__unescape(void) +{ + assert_unescape("Escaped\\", "Es\\ca\\ped\\"); + assert_unescape("Es\\caped\\", "Es\\\\ca\\ped\\\\"); + assert_unescape("\\", "\\"); + assert_unescape("\\", "\\\\"); + assert_unescape("", ""); +} diff --git a/tests-clar/status/worktree.c b/tests-clar/status/worktree.c index fed81e570..bd57cf2b6 100644 --- a/tests-clar/status/worktree.c +++ b/tests-clar/status/worktree.c @@ -517,6 +517,85 @@ void test_status_worktree__status_file_with_clean_index_and_empty_workdir(void) cl_git_pass(p_unlink("my-index")); } +void test_status_worktree__bracket_in_filename(void) +{ + git_repository *repo; + git_index *index; + status_entry_single result; + unsigned int status_flags; + int error; + + #define FILE_WITH_BRACKET "LICENSE[1].md" + #define FILE_WITHOUT_BRACKET "LICENSE1.md" + + cl_git_pass(git_repository_init(&repo, "with_bracket", 0)); + cl_git_mkfile("with_bracket/" FILE_WITH_BRACKET, "I have a bracket in my name\n"); + + /* file is new to working directory */ + + memset(&result, 0, sizeof(result)); + cl_git_pass(git_status_foreach(repo, cb_status__single, &result)); + cl_assert_equal_i(1, result.count); + cl_assert(result.status == GIT_STATUS_WT_NEW); + + cl_git_pass(git_status_file(&status_flags, repo, FILE_WITH_BRACKET)); + cl_assert(status_flags == GIT_STATUS_WT_NEW); + + /* ignore the file */ + + cl_git_rewritefile("with_bracket/.gitignore", "*.md\n.gitignore\n"); + + memset(&result, 0, sizeof(result)); + cl_git_pass(git_status_foreach(repo, cb_status__single, &result)); + cl_assert_equal_i(2, result.count); + cl_assert(result.status == GIT_STATUS_IGNORED); + + cl_git_pass(git_status_file(&status_flags, repo, FILE_WITH_BRACKET)); + cl_assert(status_flags == GIT_STATUS_IGNORED); + + /* don't ignore the file */ + + cl_git_rewritefile("with_bracket/.gitignore", ".gitignore\n"); + + memset(&result, 0, sizeof(result)); + cl_git_pass(git_status_foreach(repo, cb_status__single, &result)); + cl_assert_equal_i(2, result.count); + cl_assert(result.status == GIT_STATUS_WT_NEW); + + cl_git_pass(git_status_file(&status_flags, repo, FILE_WITH_BRACKET)); + cl_assert(status_flags == GIT_STATUS_WT_NEW); + + /* add the file to the index */ + + cl_git_pass(git_repository_index(&index, repo)); + cl_git_pass(git_index_add(index, FILE_WITH_BRACKET, 0)); + cl_git_pass(git_index_write(index)); + + memset(&result, 0, sizeof(result)); + cl_git_pass(git_status_foreach(repo, cb_status__single, &result)); + cl_assert_equal_i(2, result.count); + cl_assert(result.status == GIT_STATUS_INDEX_NEW); + + cl_git_pass(git_status_file(&status_flags, repo, FILE_WITH_BRACKET)); + cl_assert(status_flags == GIT_STATUS_INDEX_NEW); + + /* Create file without bracket */ + + cl_git_mkfile("with_bracket/" FILE_WITHOUT_BRACKET, "I have no bracket in my name!\n"); + + cl_git_pass(git_status_file(&status_flags, repo, FILE_WITHOUT_BRACKET)); + cl_assert(status_flags == GIT_STATUS_WT_NEW); + + cl_git_pass(git_status_file(&status_flags, repo, "LICENSE\\[1\\].md")); + cl_assert(status_flags == GIT_STATUS_INDEX_NEW); + + error = git_status_file(&status_flags, repo, FILE_WITH_BRACKET); + cl_git_fail(error); + cl_assert(error == GIT_EAMBIGUOUS); + + git_index_free(index); + git_repository_free(repo); +} void test_status_worktree__space_in_filename(void) { @@ -647,3 +726,49 @@ void test_status_worktree__filemode_changes(void) git_config_free(cfg); } + +int cb_status__expected_path(const char *p, unsigned int s, void *payload) +{ + const char *expected_path = (const char *)payload; + + GIT_UNUSED(s); + + if (payload == NULL) + cl_fail("Unexpected path"); + + cl_assert_equal_s(expected_path, p); + + return 0; +} + +void test_status_worktree__disable_pathspec_match(void) +{ + git_repository *repo; + git_status_options opts; + char *file_with_bracket = "LICENSE[1].md", + *imaginary_file_with_bracket = "LICENSE[1-2].md"; + + cl_git_pass(git_repository_init(&repo, "pathspec", 0)); + cl_git_mkfile("pathspec/LICENSE[1].md", "screaming bracket\n"); + cl_git_mkfile("pathspec/LICENSE1.md", "no bracket\n"); + + memset(&opts, 0, sizeof(opts)); + opts.flags = GIT_STATUS_OPT_INCLUDE_UNTRACKED | + GIT_STATUS_OPT_DISABLE_PATHSPEC_MATCH; + opts.pathspec.count = 1; + opts.pathspec.strings = &file_with_bracket; + + cl_git_pass( + git_status_foreach_ext(repo, &opts, cb_status__expected_path, + file_with_bracket) + ); + + /* Test passing a pathspec matching files in the workdir. */ + /* Must not match because pathspecs are disabled. */ + opts.pathspec.strings = &imaginary_file_with_bracket; + cl_git_pass( + git_status_foreach_ext(repo, &opts, cb_status__expected_path, NULL) + ); + + git_repository_free(repo); +}