diff --git a/include/git2/checkout.h b/include/git2/checkout.h index ffbb53fc5..8314c623d 100644 --- a/include/git2/checkout.h +++ b/include/git2/checkout.h @@ -104,6 +104,11 @@ GIT_BEGIN_DECL * overwritten. Normally, files that are ignored in the working directory * are not considered "precious" and may be overwritten if the checkout * target contains that file. + * + * - GIT_CHECKOUT_DONT_REMOVE_EXISTING prevents checkout from removing + * files or folders that fold to the same name on case insensitive + * filesystems. This can cause files to retain their existing names + * and write through existing symbolic links. */ typedef enum { GIT_CHECKOUT_NONE = 0, /**< default is a dry run, no actual updates */ @@ -158,6 +163,9 @@ typedef enum { /** Include common ancestor data in diff3 format files for conflicts */ GIT_CHECKOUT_CONFLICT_STYLE_DIFF3 = (1u << 21), + /** Don't overwrite existing files or folders */ + GIT_CHECKOUT_DONT_REMOVE_EXISTING = (1u << 22), + /** * THE FOLLOWING OPTIONS ARE NOT YET IMPLEMENTED */ diff --git a/src/checkout.c b/src/checkout.c index d3c403b03..73750f7f6 100644 --- a/src/checkout.c +++ b/src/checkout.c @@ -1310,10 +1310,27 @@ static int checkout_mkdir( return error; } +static bool should_remove_existing(checkout_data *data) +{ + int ignorecase = 0; + + git_repository__cvar(&ignorecase, data->repo, GIT_CVAR_IGNORECASE); + + return (ignorecase && + (data->strategy & GIT_CHECKOUT_DONT_REMOVE_EXISTING) == 0); +} + +#define MKDIR_NORMAL \ + GIT_MKDIR_PATH | GIT_MKDIR_VERIFY_DIR +#define MKDIR_REMOVE_EXISTING \ + MKDIR_NORMAL | GIT_MKDIR_REMOVE_FILES | GIT_MKDIR_REMOVE_SYMLINKS + static int mkpath2file( checkout_data *data, const char *path, unsigned int mode) { git_buf *mkdir_path = &data->tmp; + struct stat st; + bool remove_existing = should_remove_existing(data); int error; if ((error = git_buf_sets(mkdir_path, path)) < 0) @@ -1321,14 +1338,36 @@ static int mkpath2file( git_buf_rtruncate_at_char(mkdir_path, '/'); - if (data->last_mkdir.size && mkdir_path->size == data->last_mkdir.size && - memcmp(mkdir_path->ptr, data->last_mkdir.ptr, mkdir_path->size) == 0) - return 0; + if (!data->last_mkdir.size || + data->last_mkdir.size != mkdir_path->size || + memcmp(mkdir_path->ptr, data->last_mkdir.ptr, mkdir_path->size) != 0) { + + if ((error = checkout_mkdir( + data, mkdir_path->ptr, data->opts.target_directory, mode, + remove_existing ? MKDIR_REMOVE_EXISTING : MKDIR_NORMAL)) < 0) + return error; - if ((error = checkout_mkdir( - data, mkdir_path->ptr, data->opts.target_directory, mode, - GIT_MKDIR_PATH | GIT_MKDIR_VERIFY_DIR)) == 0) git_buf_swap(&data->last_mkdir, mkdir_path); + } + + if (remove_existing) { + data->perfdata.stat_calls++; + + if (p_lstat(path, &st) == 0) { + + /* Some file, symlink or folder already exists at this name. + * We would have removed it in remove_the_old unless we're on + * a case inensitive filesystem (or the user has asked us not + * to). Remove the similarly named file to write the new. + */ + error = git_futils_rmdir_r(path, NULL, GIT_RMDIR_REMOVE_FILES); + } else if (errno != ENOENT) { + giterr_set(GITERR_OS, "Failed to stat file '%s'", path); + return GIT_EEXISTS; + } else { + giterr_clear(); + } + } return error; } @@ -1489,6 +1528,7 @@ static int checkout_submodule( checkout_data *data, const git_diff_file *file) { + bool remove_existing = should_remove_existing(data); int error = 0; /* Until submodules are supported, UPDATE_ONLY means do nothing here */ @@ -1497,8 +1537,8 @@ static int checkout_submodule( if ((error = checkout_mkdir( data, - file->path, data->opts.target_directory, - data->opts.dir_mode, GIT_MKDIR_PATH)) < 0) + file->path, data->opts.target_directory, data->opts.dir_mode, + remove_existing ? MKDIR_REMOVE_EXISTING : MKDIR_NORMAL)) < 0) return error; if ((error = git_submodule_lookup(NULL, data->repo, file->path)) < 0) { diff --git a/src/fileops.c b/src/fileops.c index ea0f4e1f7..2ee9535be 100644 --- a/src/fileops.c +++ b/src/fileops.c @@ -279,6 +279,48 @@ void git_futils_mmap_free(git_map *out) p_munmap(out); } +GIT_INLINE(int) validate_existing( + const char *make_path, + struct stat *st, + mode_t mode, + uint32_t flags, + struct git_futils_mkdir_perfdata *perfdata) +{ + if ((S_ISREG(st->st_mode) && (flags & GIT_MKDIR_REMOVE_FILES)) || + (S_ISLNK(st->st_mode) && (flags & GIT_MKDIR_REMOVE_SYMLINKS))) { + if (p_unlink(make_path) < 0) { + giterr_set(GITERR_OS, "Failed to remove %s '%s'", + S_ISLNK(st->st_mode) ? "symlink" : "file", make_path); + return GIT_EEXISTS; + } + + perfdata->mkdir_calls++; + + if (p_mkdir(make_path, mode) < 0) { + giterr_set(GITERR_OS, "Failed to make directory '%s'", make_path); + return GIT_EEXISTS; + } + } + + else if (S_ISLNK(st->st_mode)) { + /* Re-stat the target, make sure it's a directory */ + perfdata->stat_calls++; + + if (p_stat(make_path, st) < 0) { + giterr_set(GITERR_OS, "Failed to make directory '%s'", make_path); + return GIT_EEXISTS; + } + } + + else if (!S_ISDIR(st->st_mode)) { + giterr_set(GITERR_FILESYSTEM, + "Failed to make directory '%s': directory exists", make_path); + return GIT_EEXISTS; + } + + return 0; +} + int git_futils_mkdir_withperf( const char *path, const char *base, @@ -373,22 +415,9 @@ int git_futils_mkdir_withperf( goto done; } - if (S_ISLNK(st.st_mode)) { - perfdata->stat_calls++; - - /* Re-stat the target, make sure it's a directory */ - if (p_stat(make_path.ptr, &st) < 0) { - giterr_set(GITERR_OS, "Failed to make directory '%s'", make_path.ptr); - error = GIT_EEXISTS; + if ((error = validate_existing( + make_path.ptr, &st, mode, flags, perfdata)) < 0) goto done; - } - } - - if (!S_ISDIR(st.st_mode)) { - giterr_set(GITERR_FILESYSTEM, "Failed to make directory '%s': directory exists", make_path.ptr); - error = GIT_EEXISTS; - goto done; - } } /* chmod if requested and necessary */ @@ -400,7 +429,8 @@ int git_futils_mkdir_withperf( if ((error = p_chmod(make_path.ptr, mode)) < 0 && lastch == '\0') { - giterr_set(GITERR_OS, "Failed to set permissions on '%s'", make_path.ptr); + giterr_set(GITERR_OS, "Failed to set permissions on '%s'", + make_path.ptr); goto done; } } @@ -414,7 +444,8 @@ int git_futils_mkdir_withperf( perfdata->stat_calls++; if (p_stat(make_path.ptr, &st) < 0 || !S_ISDIR(st.st_mode)) { - giterr_set(GITERR_OS, "Path is not a directory '%s'", make_path.ptr); + giterr_set(GITERR_OS, "Path is not a directory '%s'", + make_path.ptr); error = GIT_ENOTFOUND; } } diff --git a/src/fileops.h b/src/fileops.h index 65b59522c..4aaf1781c 100644 --- a/src/fileops.h +++ b/src/fileops.h @@ -70,6 +70,8 @@ extern int git_futils_mkdir_r(const char *path, const char *base, const mode_t m * * GIT_MKDIR_SKIP_LAST says to leave off the last element of the path * * GIT_MKDIR_SKIP_LAST2 says to leave off the last 2 elements of the path * * GIT_MKDIR_VERIFY_DIR says confirm final item is a dir, not just EEXIST + * * GIT_MKDIR_REMOVE_FILES says to remove files and recreate dirs + * * GIT_MKDIR_REMOVE_SYMLINKS says to remove symlinks and recreate dirs * * Note that the chmod options will be executed even if the directory already * exists, unless GIT_MKDIR_EXCL is given. @@ -82,6 +84,8 @@ typedef enum { GIT_MKDIR_SKIP_LAST = 16, GIT_MKDIR_SKIP_LAST2 = 32, GIT_MKDIR_VERIFY_DIR = 64, + GIT_MKDIR_REMOVE_FILES = 128, + GIT_MKDIR_REMOVE_SYMLINKS = 256, } git_futils_mkdir_flags; struct git_futils_mkdir_perfdata diff --git a/tests/checkout/icase.c b/tests/checkout/icase.c new file mode 100644 index 000000000..625f19627 --- /dev/null +++ b/tests/checkout/icase.c @@ -0,0 +1,97 @@ +#include "clar_libgit2.h" + +#include "git2/checkout.h" +#include "path.h" + +static git_repository *repo; +static git_object *obj; +static git_checkout_options checkout_opts; + +void test_checkout_icase__initialize(void) +{ + git_oid id; + + repo = cl_git_sandbox_init("testrepo"); + + cl_git_pass(git_reference_name_to_id(&id, repo, "refs/heads/dir")); + cl_git_pass(git_object_lookup(&obj, repo, &id, GIT_OBJ_ANY)); + + git_checkout_init_options(&checkout_opts, GIT_CHECKOUT_OPTIONS_VERSION); + checkout_opts.checkout_strategy = GIT_CHECKOUT_FORCE; +} + +void test_checkout_icase__cleanup(void) +{ + git_object_free(obj); + cl_git_sandbox_cleanup(); +} + +static void assert_name_is(const char *expected) +{ + char *actual; + size_t actual_len, expected_len, start; + + cl_assert(actual = realpath(expected, NULL)); + + expected_len = strlen(expected); + actual_len = strlen(actual); + cl_assert(actual_len >= expected_len); + + start = actual_len - expected_len; + cl_assert_equal_s(expected, actual + start); + + if (start) + cl_assert_equal_strn("/", actual + (start - 1), 1); + + free(actual); +} + +void test_checkout_icase__overwrites_files_for_files(void) +{ + cl_git_write2file("testrepo/NEW.txt", "neue file\n", 10, \ + O_WRONLY | O_CREAT | O_TRUNC, 0644); + + cl_git_pass(git_checkout_tree(repo, obj, &checkout_opts)); + assert_name_is("testrepo/new.txt"); +} + +void test_checkout_icase__overwrites_links_for_files(void) +{ + cl_must_pass(p_symlink("../tmp", "testrepo/NEW.txt")); + + cl_git_pass(git_checkout_tree(repo, obj, &checkout_opts)); + + cl_assert(!git_path_exists("tmp")); + assert_name_is("testrepo/new.txt"); +} + +void test_checkout_icase__overwites_folders_for_files(void) +{ + cl_must_pass(p_mkdir("testrepo/NEW.txt", 0777)); + + cl_git_pass(git_checkout_tree(repo, obj, &checkout_opts)); + + assert_name_is("testrepo/new.txt"); + cl_assert(!git_path_isdir("testrepo/new.txt")); +} + +void test_checkout_icase__overwrites_files_for_folders(void) +{ + cl_git_write2file("testrepo/A", "neue file\n", 10, \ + O_WRONLY | O_CREAT | O_TRUNC, 0644); + + cl_git_pass(git_checkout_tree(repo, obj, &checkout_opts)); + assert_name_is("testrepo/a"); + cl_assert(git_path_isdir("testrepo/a")); +} + +void test_checkout_icase__overwrites_links_for_folders(void) +{ + cl_must_pass(p_symlink("..", "testrepo/A")); + + cl_git_pass(git_checkout_tree(repo, obj, &checkout_opts)); + + cl_assert(!git_path_exists("b.txt")); + assert_name_is("testrepo/a"); +} + diff --git a/tests/checkout/index.c b/tests/checkout/index.c index f94556214..112324a04 100644 --- a/tests/checkout/index.c +++ b/tests/checkout/index.c @@ -279,10 +279,10 @@ void test_checkout_index__options_open_flags(void) cl_git_mkfile("./testrepo/new.txt", "hi\n"); - opts.checkout_strategy = GIT_CHECKOUT_SAFE_CREATE; + opts.checkout_strategy = + GIT_CHECKOUT_FORCE | GIT_CHECKOUT_DONT_REMOVE_EXISTING; opts.file_open_flags = O_CREAT | O_RDWR | O_APPEND; - opts.checkout_strategy = GIT_CHECKOUT_FORCE; cl_git_pass(git_checkout_index(g_repo, NULL, &opts)); check_file_contents("./testrepo/new.txt", "hi\nmy new file\n");