diff --git a/Documentation/config/worktree.adoc b/Documentation/config/worktree.adoc index 0930183b91cb8f..cd7cf186dea91c 100644 --- a/Documentation/config/worktree.adoc +++ b/Documentation/config/worktree.adoc @@ -9,6 +9,11 @@ fails. If no such match can be found, it falls back to creating a new branch from the current `HEAD`. +`worktree.copyOnWrite`:: + When creating a linked worktree, use copy-on-write file copies when + supported by the platform and file system. Setting this to `false` + disables the optimization. Defaults to `true`. + `worktree.useRelativePaths`:: Link worktrees using relative paths (when "`true`") or absolute paths (when "`false`"). This is particularly useful for setups diff --git a/Makefile b/Makefile index c98be9155c54ae..31c529ffd970b0 100644 --- a/Makefile +++ b/Makefile @@ -819,6 +819,7 @@ TEST_BUILTINS_OBJS += test-bundle-uri.o TEST_BUILTINS_OBJS += test-cache-tree.o TEST_BUILTINS_OBJS += test-chmtime.o TEST_BUILTINS_OBJS += test-config.o +TEST_BUILTINS_OBJS += test-copy-on-write.o TEST_BUILTINS_OBJS += test-crontab.o TEST_BUILTINS_OBJS += test-csprng.o TEST_BUILTINS_OBJS += test-date.o diff --git a/builtin/reset.c b/builtin/reset.c index 66c26eab62fc12..eafffc8a908f77 100644 --- a/builtin/reset.c +++ b/builtin/reset.c @@ -23,6 +23,7 @@ #include "refs.h" #include "diff.h" #include "diffcore.h" +#include "entry.h" #include "tree.h" #include "branch.h" #include "object-name.h" @@ -62,7 +63,8 @@ static inline int is_merge(void) return !access(git_path_merge_head(the_repository), F_OK); } -static int reset_index(const char *ref, const struct object_id *oid, int reset_type, int quiet) +static int reset_index(const char *ref, const struct object_id *oid, int reset_type, int quiet, + struct checkout_copy_source *copy_source) { int i, nr = 0; struct tree_desc desc[2]; @@ -76,6 +78,7 @@ static int reset_index(const char *ref, const struct object_id *oid, int reset_t opts.dst_index = the_repository->index; opts.fn = oneway_merge; opts.merge = 1; + opts.copy_source = copy_source; init_checkout_metadata(&opts.meta, ref, oid, NULL); if (!quiet) opts.verbose_update = 1; @@ -336,6 +339,38 @@ static int git_reset_config(const char *var, const char *value, return git_default_config(var, value, ctx, cb); } +static int init_copy_source(struct checkout_copy_source *source, + struct index_state *source_index) +{ + const char *worktree = getenv(GIT_WORKTREE_COPY_SOURCE); + const char *index_file = getenv(GIT_WORKTREE_COPY_SOURCE_INDEX); + const char *git_dir = getenv(GIT_WORKTREE_COPY_SOURCE_GIT_DIR); + const char *time_string = getenv(GIT_WORKTREE_COPY_SOURCE_TIME); + char *end; + int enabled = 1; + uintmax_t refreshed_at; + + if (!copy_on_write_supported || + !worktree || !index_file || !git_dir || !time_string) + return 0; + if (!repo_config_get_bool(the_repository, "worktree.copyonwrite", + &enabled) && !enabled) + return 0; + + errno = 0; + refreshed_at = strtoumax(time_string, &end, 10); + if (errno || *end) + return 0; + if (read_index_from(source_index, index_file, git_dir) < 0) + return 0; + ensure_full_index(source_index); + + source->istate = source_index; + source->worktree = worktree; + source->refreshed_at = refreshed_at; + return 1; +} + int cmd_reset(int argc, const char **argv, const char *prefix, @@ -343,6 +378,10 @@ int cmd_reset(int argc, { int reset_type = NONE, update_ref_status = 0, quiet = 0; int no_refresh = 0; + struct index_state copy_source_index = + INDEX_STATE_INIT(the_repository); + struct checkout_copy_source copy_source = { 0 }; + struct checkout_copy_source *copy_source_ptr = NULL; int patch_mode = 0, pathspec_file_nul = 0, unborn; const char *rev; char *pathspec_from_file = NULL; @@ -495,6 +534,9 @@ int cmd_reset(int argc, if (repo_read_index(the_repository) < 0) die(_("index file corrupt")); + if (reset_type == HARD && + init_copy_source(©_source, ©_source_index)) + copy_source_ptr = ©_source; /* Soft reset does not touch the index file nor the working tree * at all, but requires them in a good order. Other resets reset @@ -535,9 +577,11 @@ int cmd_reset(int argc, if (ref && !starts_with(ref, "refs/")) FREE_AND_NULL(ref); - err = reset_index(ref, &oid, reset_type, quiet); + err = reset_index(ref, &oid, reset_type, quiet, + copy_source_ptr); if (reset_type == KEEP && !err) - err = reset_index(ref, &oid, MIXED, quiet); + err = reset_index(ref, &oid, MIXED, quiet, + copy_source_ptr); if (err) die(_("Could not reset index file to revision '%s'."), rev); free(ref); @@ -561,6 +605,7 @@ int cmd_reset(int argc, discard_index(the_repository->index); cleanup: + release_index(©_source_index); clear_pathspec(&pathspec); free(pathspec_from_file); return update_ref_status; diff --git a/builtin/worktree.c b/builtin/worktree.c index 77ecd0f71f4247..df53869499b904 100644 --- a/builtin/worktree.c +++ b/builtin/worktree.c @@ -398,11 +398,36 @@ static int checkout_worktree(const struct add_opts *opts, struct strvec *child_env) { struct child_process cp = CHILD_PROCESS_INIT; + const char *source = repo_get_work_tree(the_repository); + struct timeval refreshed_at; + cp.git_cmd = 1; strvec_pushl(&cp.args, "reset", "--hard", "--no-recurse-submodules", NULL); if (opts->quiet) strvec_push(&cp.args, "--quiet"); strvec_pushv(&cp.env, child_env->v); + if (copy_on_write_supported && source && + repo_read_index(the_repository) >= 0) { + gettimeofday(&refreshed_at, NULL); + if (repo_refresh_and_write_index(the_repository, + REFRESH_QUIET | REFRESH_REALLY, + SKIP_IF_UNCHANGED, 1, + NULL, NULL, NULL) >= 0) { + strvec_pushf(&cp.env, "%s=%s", + GIT_WORKTREE_COPY_SOURCE, source); + strvec_pushf(&cp.env, "%s=%s", + GIT_WORKTREE_COPY_SOURCE_INDEX, + repo_get_index_file(the_repository)); + strvec_pushf(&cp.env, "%s=%s", + GIT_WORKTREE_COPY_SOURCE_GIT_DIR, + repo_get_git_dir(the_repository)); + strvec_pushf(&cp.env, "%s=%"PRIuMAX, + GIT_WORKTREE_COPY_SOURCE_TIME, + (uintmax_t)refreshed_at.tv_sec * + 1000000000 + + refreshed_at.tv_usec * 1000); + } + } return run_command(&cp); } diff --git a/compat/mingw.c b/compat/mingw.c index fd014eef498e55..e37f74e5d0f4df 100644 --- a/compat/mingw.c +++ b/compat/mingw.c @@ -33,9 +33,60 @@ #include #define STATUS_DELETE_PENDING ((NTSTATUS) 0xC0000056) - #define HCAST(type, handle) ((type)(intptr_t)handle) +int mingw_file_copy_on_write(int dst_fd, int src_fd, off_t size) +{ + DUPLICATE_EXTENTS_DATA data = { + .FileHandle = HCAST(HANDLE, _get_osfhandle(src_fd)), + }; + HANDLE dst = HCAST(HANDLE, _get_osfhandle(dst_fd)); + /* + * FSCTL_DUPLICATE_EXTENTS_TO_FILE accepts ranges smaller than 4 GiB, + * aligned to the volume's cluster size. ReFS uses 64 KiB or 4 KiB + * clusters, so this is the largest valid range for either. + */ + const LONGLONG max_range = (4LL * 1024 * 1024 * 1024) - (64 * 1024); + const LONGLONG tail_alignments[] = { 64 * 1024, 4 * 1024 }; + DWORD bytes_returned; + size_t i; + + /* The destination range must exist before extents can be cloned. */ + if (ftruncate(dst_fd, size) < 0) + return -1; + if (!size) + return 0; + + while (data.SourceFileOffset.QuadPart + max_range < size) { + data.ByteCount.QuadPart = max_range; + if (!DeviceIoControl(dst, FSCTL_DUPLICATE_EXTENTS_TO_FILE, + &data, sizeof(data), NULL, 0, + &bytes_returned, NULL)) { + errno = ENOSYS; + return -1; + } + data.SourceFileOffset.QuadPart += data.ByteCount.QuadPart; + data.TargetFileOffset = data.SourceFileOffset; + } + + for (i = 0; i < ARRAY_SIZE(tail_alignments); i++) { + LONGLONG remaining = size - data.SourceFileOffset.QuadPart; + + data.ByteCount.QuadPart = + DIV_ROUND_UP(remaining, tail_alignments[i]) * + tail_alignments[i]; + if (DeviceIoControl(dst, FSCTL_DUPLICATE_EXTENTS_TO_FILE, + &data, sizeof(data), NULL, 0, + &bytes_returned, NULL)) { + /* Discard any allocation-unit padding in the tail. */ + return ftruncate(dst_fd, size); + } + } + + errno = ENOSYS; + return -1; +} + void open_in_gdb(void) { static struct child_process cp = CHILD_PROCESS_INIT; diff --git a/compat/mingw.h b/compat/mingw.h index 379d7f5d079165..69560b2e1d6e4a 100644 --- a/compat/mingw.h +++ b/compat/mingw.h @@ -7,6 +7,9 @@ struct config_context; int mingw_core_config(const char *var, const char *value, const struct config_context *ctx, void *cb); #define platform_core_config mingw_core_config +#define copy_on_write_supported 1 +int mingw_file_copy_on_write(int dst_fd, int src_fd, off_t size); +#define file_copy_on_write mingw_file_copy_on_write #ifndef NO_OPENSSL #include diff --git a/entry.c b/entry.c index 5fd4b08991c6ed..2c3233d0f58c0c 100644 --- a/entry.c +++ b/entry.c @@ -15,6 +15,8 @@ #include "fsmonitor.h" #include "entry.h" #include "parallel-checkout.h" +#include "trace2.h" +#include "wrapper.h" static void create_directories(const char *path, int path_len, const struct checkout *state) @@ -128,6 +130,105 @@ static int open_output_fd(char *path, const struct cache_entry *ce, int to_tempf } } +static int conversion_attrs_equal(const struct conv_attrs *a, + const struct conv_attrs *b) +{ + return a->drv == b->drv && + a->crlf_action == b->crlf_action && + a->ident == b->ident && + (!a->working_tree_encoding == !b->working_tree_encoding) && + (!a->working_tree_encoding || + !strcmp(a->working_tree_encoding, b->working_tree_encoding)); +} + +static struct cache_entry *copy_source_entry(const struct cache_entry *ce, + const struct conv_attrs *ca, + const struct checkout *state) +{ + struct index_state *istate; + struct cache_entry *source; + struct conv_attrs source_ca; + int pos; + + if (!state->copy_source || !ca || !S_ISREG(ce->ce_mode)) + return NULL; + + istate = state->copy_source->istate; + pos = index_name_pos(istate, ce->name, ce_namelen(ce)); + if (pos < 0) + return NULL; + source = istate->cache[pos]; + if (!S_ISREG(source->ce_mode) || ce_skip_worktree(source) || + !oideq(&source->oid, &ce->oid)) + return NULL; + convert_attrs(istate, &source_ca, ce->name); + if (!conversion_attrs_equal(ca, &source_ca)) + return NULL; + + return source; +} + +static int source_is_uptodate(const struct checkout_copy_source *source, + const struct cache_entry *ce, struct stat *st) +{ + uint64_t mtime; + + if (match_stat_data(&ce->ce_stat_data, st)) + return 0; + + mtime = (uint64_t)st->st_mtime * 1000000000 + ST_MTIME_NSEC(*st); + return mtime < source->refreshed_at; +} + +static int try_copy_on_write(const struct cache_entry *ce, + const struct cache_entry *source_ce, char *path, + const struct checkout *state, + int *fstat_done, struct stat *statbuf) +{ + struct strbuf source = STRBUF_INIT; + struct stat st, st_after; + int src_fd = -1, dst_fd = -1; + int ret = 0; + + strbuf_addf(&source, "%s/%s", state->copy_source->worktree, ce->name); + src_fd = open_nofollow(source.buf, O_RDONLY); + if (src_fd < 0 || fstat(src_fd, &st) || !S_ISREG(st.st_mode) || + !source_is_uptodate(state->copy_source, source_ce, &st)) + goto done; + + dst_fd = open_output_fd(path, ce, 0); + if (dst_fd < 0) + goto done; + if (file_copy_on_write(dst_fd, src_fd, st.st_size) || + fstat(src_fd, &st_after) || + !source_is_uptodate(state->copy_source, source_ce, &st_after)) { + close(dst_fd); + dst_fd = -1; + unlink(path); + goto done; + } + if (close(dst_fd)) { + dst_fd = -1; + unlink(path); + goto done; + } + dst_fd = -1; + + if (state->refresh_cache) + *fstat_done = !lstat(path, statbuf); + trace2_data_string("checkout", the_repository, + "copy_on_write", ce->name); + ret = 1; + +done: + if (dst_fd >= 0) + close(dst_fd); + if (src_fd >= 0) + close(src_fd); + strbuf_release(&source); + return ret; +} + int fstat_checkout_output(int fd, const struct checkout *state, struct stat *st) { /* use fstat() only when path == ce->name */ @@ -294,6 +395,7 @@ void update_ce_after_write(const struct checkout *state, struct cache_entry *ce, /* Note: ca is used (and required) iff the entry refers to a regular file. */ static int write_entry(struct cache_entry *ce, char *path, struct conv_attrs *ca, + const struct cache_entry *source_ce, const struct checkout *state, int to_tempfile, int *nr_checkouts) { @@ -313,7 +415,14 @@ static int write_entry(struct cache_entry *ce, char *path, struct conv_attrs *ca clone_checkout_metadata(&meta, &state->meta, &ce->oid); if (ce_mode_s_ifmt == S_IFREG) { - struct stream_filter *filter = get_stream_filter_ca(ca, &ce->oid); + struct stream_filter *filter; + + if (source_ce && + try_copy_on_write(ce, source_ce, path, state, + &fstat_done, &st)) + goto finish; + + filter = get_stream_filter_ca(ca, &ce->oid); if (filter && !streaming_write_entry(ce, path, filter, state, to_tempfile, @@ -502,6 +611,7 @@ int checkout_entry_ca(struct cache_entry *ce, struct conv_attrs *ca, static struct strbuf path = STRBUF_INIT; struct stat st; struct conv_attrs ca_buf; + struct cache_entry *source_ce = NULL; if (ce->ce_flags & CE_WT_REMOVE) { if (topath) @@ -519,7 +629,8 @@ int checkout_entry_ca(struct cache_entry *ce, struct conv_attrs *ca, convert_attrs(state->istate, &ca_buf, ce->name); ca = &ca_buf; } - return write_entry(ce, topath, ca, state, 1, nr_checkouts); + return write_entry(ce, topath, ca, NULL, state, 1, + nr_checkouts); } strbuf_reset(&path); @@ -602,10 +713,12 @@ int checkout_entry_ca(struct cache_entry *ce, struct conv_attrs *ca, ca = &ca_buf; } - if (!enqueue_checkout(ce, ca, nr_checkouts)) + source_ce = copy_source_entry(ce, ca, state); + if (!source_ce && !enqueue_checkout(ce, ca, nr_checkouts)) return 0; - return write_entry(ce, path.buf, ca, state, 0, nr_checkouts); + return write_entry(ce, path.buf, ca, source_ce, state, 0, + nr_checkouts); } void unlink_entry(const struct cache_entry *ce, const char *super_prefix) diff --git a/entry.h b/entry.h index ca3ed35bc08654..c24c353f5ba22c 100644 --- a/entry.h +++ b/entry.h @@ -6,6 +6,12 @@ struct cache_entry; struct index_state; +struct checkout_copy_source { + struct index_state *istate; + const char *worktree; + uint64_t refreshed_at; +}; + struct checkout { struct index_state *istate; const char *base_dir; @@ -13,6 +19,7 @@ struct checkout { const char *super_prefix; struct delayed_checkout *delayed_checkout; struct checkout_metadata meta; + struct checkout_copy_source *copy_source; unsigned force:1, quiet:1, not_new:1, diff --git a/environment.h b/environment.h index e7ec5b0437342d..33d301903c8305 100644 --- a/environment.h +++ b/environment.h @@ -9,6 +9,10 @@ #define GIT_COMMON_DIR_ENVIRONMENT "GIT_COMMON_DIR" #define GIT_NAMESPACE_ENVIRONMENT "GIT_NAMESPACE" #define GIT_WORK_TREE_ENVIRONMENT "GIT_WORK_TREE" +#define GIT_WORKTREE_COPY_SOURCE "GIT_WORKTREE_COPY_SOURCE" +#define GIT_WORKTREE_COPY_SOURCE_INDEX "GIT_WORKTREE_COPY_SOURCE_INDEX" +#define GIT_WORKTREE_COPY_SOURCE_GIT_DIR "GIT_WORKTREE_COPY_SOURCE_GIT_DIR" +#define GIT_WORKTREE_COPY_SOURCE_TIME "GIT_WORKTREE_COPY_SOURCE_TIME" #define GIT_PREFIX_ENVIRONMENT "GIT_PREFIX" #define DEFAULT_GIT_DIR_ENVIRONMENT ".git" #define DB_ENVIRONMENT "GIT_OBJECT_DIRECTORY" diff --git a/git-compat-util.h b/git-compat-util.h index 10d3eec3c74ba5..58fc0bbdeb91b7 100644 --- a/git-compat-util.h +++ b/git-compat-util.h @@ -223,6 +223,14 @@ static inline int noop_core_config(const char *var UNUSED, #define platform_core_config noop_core_config #endif +#ifndef copy_on_write_supported +#define copy_on_write_supported 0 +#endif + +#ifndef file_copy_on_write +#define file_copy_on_write(dst_fd, src_fd, size) (errno = ENOSYS, -1) +#endif + #ifndef has_dos_drive_prefix static inline int git_has_dos_drive_prefix(const char *path UNUSED) { diff --git a/t/helper/test-copy-on-write.c b/t/helper/test-copy-on-write.c new file mode 100644 index 00000000000000..fe740cf9ebf90a --- /dev/null +++ b/t/helper/test-copy-on-write.c @@ -0,0 +1,28 @@ +#include "test-tool.h" + +int cmd__copy_on_write(int argc, const char **argv) +{ + struct stat st; + int src_fd, dst_fd; + int ret; + + if (argc != 3) + return 1; + + src_fd = open(argv[1], O_RDONLY); + if (src_fd < 0 || fstat(src_fd, &st)) + return 1; + dst_fd = open(argv[2], O_WRONLY | O_CREAT | O_EXCL, 0666); + if (dst_fd < 0) { + close(src_fd); + return 1; + } + + ret = file_copy_on_write(dst_fd, src_fd, st.st_size); + if (close(dst_fd)) + ret = -1; + close(src_fd); + if (ret) + unlink(argv[2]); + return !!ret; +} diff --git a/t/helper/test-tool.c b/t/helper/test-tool.c index ee16b2cb23719e..81407f95dbbe77 100644 --- a/t/helper/test-tool.c +++ b/t/helper/test-tool.c @@ -17,6 +17,7 @@ static struct test_cmd cmds[] = { { "cache-tree", cmd__cache_tree }, { "chmtime", cmd__chmtime }, { "config", cmd__config }, + { "copy-on-write", cmd__copy_on_write }, { "crontab", cmd__crontab }, { "csprng", cmd__csprng }, { "date", cmd__date }, diff --git a/t/helper/test-tool.h b/t/helper/test-tool.h index 4cf9f935a4cdfa..4bebafc12c619b 100644 --- a/t/helper/test-tool.h +++ b/t/helper/test-tool.h @@ -10,6 +10,7 @@ int cmd__bundle_uri(int argc, const char **argv); int cmd__cache_tree(int argc, const char **argv); int cmd__chmtime(int argc, const char **argv); int cmd__config(int argc, const char **argv); +int cmd__copy_on_write(int argc, const char **argv); int cmd__crontab(int argc, const char **argv); int cmd__csprng(int argc, const char **argv); int cmd__date(int argc, const char **argv); diff --git a/t/t2400-worktree-add.sh b/t/t2400-worktree-add.sh index bdcca97633bc10..6131724a50850d 100755 --- a/t/t2400-worktree-add.sh +++ b/t/t2400-worktree-add.sh @@ -10,6 +10,13 @@ TEST_CREATE_REPO_NO_TEMPLATE=1 . "$TEST_DIRECTORY"/lib-rebase.sh +test_lazy_prereq COPY_ON_WRITE ' + test-tool genzeros 65536 >copy-source && + test-tool copy-on-write copy-source copy-target && + test_cmp copy-source copy-target && + rm copy-source copy-target +' + test_expect_success 'setup' ' test_commit init ' @@ -71,6 +78,37 @@ test_expect_success '"add" worktree' ' ) ' +test_expect_success COPY_ON_WRITE 'worktree add uses copy-on-write' ' + old_head=$(git rev-parse HEAD) && + test_when_finished "git reset --hard $old_head" && + test_when_finished "git worktree remove --force copy-on-write" && + echo "*.crlf text eol=crlf" >.gitattributes && + echo clean >clean && + echo clean >clean.crlf && + echo clean >modified.crlf && + git add .gitattributes clean clean.crlf modified.crlf && + git commit -m "copy-on-write test files" && + rm clean.crlf modified.crlf && + git checkout -- clean.crlf modified.crlf && + echo modified >init.t && + echo modified >modified.crlf && + GIT_TRACE2_EVENT="$PWD/trace" \ + git worktree add --detach copy-on-write main && + test_grep "\"key\":\"copy_on_write\",\"value\":\"clean\"" trace && + test_grep "\"key\":\"copy_on_write\",\"value\":\"clean.crlf\"" trace && + test_must_fail test_grep "\"value\":\"init.t\"" trace && + test_must_fail test_grep "\"value\":\"modified.crlf\"" trace && + git -C copy-on-write diff --exit-code +' + +test_expect_success COPY_ON_WRITE 'worktree.copyOnWrite disables optimization' ' + test_when_finished "git worktree remove --force no-copy-on-write" && + test_config worktree.copyOnWrite false && + GIT_TRACE2_EVENT="$PWD/disabled-trace" \ + git worktree add --detach no-copy-on-write main && + test_must_fail test_grep "\"key\":\"copy_on_write\"" disabled-trace +' + test_expect_success '"add" worktree with lock' ' git worktree add --detach --lock here-with-lock main && test_when_finished "git worktree unlock here-with-lock || :" && diff --git a/unpack-trees.c b/unpack-trees.c index 7958e1f25848cc..cbde1070fe1686 100644 --- a/unpack-trees.c +++ b/unpack-trees.c @@ -437,6 +437,7 @@ static int check_updates(struct unpack_trees_options *o, state.quiet = 1; state.refresh_cache = 1; state.istate = index; + state.copy_source = o->copy_source; clone_checkout_metadata(&state.meta, &o->meta, NULL); if (!o->update || o->dry_run) { diff --git a/unpack-trees.h b/unpack-trees.h index 5867e26e177774..1faaa648abbb0d 100644 --- a/unpack-trees.h +++ b/unpack-trees.h @@ -10,6 +10,7 @@ #define MAX_UNPACK_TREES 8 struct cache_entry; +struct checkout_copy_source; struct unpack_trees_options; struct pattern_list; @@ -74,6 +75,8 @@ struct unpack_trees_options { enum unpack_trees_reset_type reset; const char *prefix; const char *super_prefix; + /* Caller-owned source and index, valid for this unpack operation. */ + struct checkout_copy_source *copy_source; struct pathspec *pathspec; merge_fn_t fn;