Commit 8d6552396ceae7736522c86dbef812e12bb1968f

Edward Thomson 2015-01-16T18:37:06

checkout: remove files before writing new ones On case insensitive filesystems, we may have files in the working directory that case fold to a name we want to write. Remove those files (by default) so that we will not end up with a filename that has the unexpected case.

diff --git a/include/git2/checkout.h b/include/git2/checkout.h
index fe4966a..4be08a2 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 a7702d2..713bc2c 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 8d192f1..59882a9 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_INVALID,
+			"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_INVALID, "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 65b5952..4aaf178 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 0000000..625f196
--- /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 f945562..112324a 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");