From f1d8ff2b0cf13c52d349b7ae6e6e038c55bb9e35 Mon Sep 17 00:00:00 2001 From: Walter Gray Date: Tue, 14 Jul 2026 18:38:25 -0700 Subject: [PATCH 1/7] wrapper: add reflink_file() helper Add a helper that clones a file using the FICLONE ioctl on Linux, creating the destination as a lightweight copy-on-write copy that shares extents with the source on filesystems that support it (e.g. btrfs, XFS, bcachefs). On other platforms the helper always fails with ENOSYS; callers are expected to fall back to a regular copy. Also add a test-tool command and a REFLINK test prerequisite so tests can probe whether the filesystem the test suite runs on supports reflinking. This will be used to speed up populating new worktrees. Co-Authored-By: Claude Fable 5 Signed-off-by: Walter Gray --- Makefile | 1 + t/helper/meson.build | 1 + t/helper/test-reflink.c | 13 +++++++++++ t/helper/test-tool.c | 1 + t/helper/test-tool.h | 1 + t/t2408-worktree-reflink.sh | 18 ++++++++++++++++ t/test-lib.sh | 6 ++++++ wrapper.c | 43 +++++++++++++++++++++++++++++++++++++ wrapper.h | 9 ++++++++ 9 files changed, 93 insertions(+) create mode 100644 t/helper/test-reflink.c create mode 100755 t/t2408-worktree-reflink.sh diff --git a/Makefile b/Makefile index 1f3f099f5c5705..6c79dd2bc3c372 100644 --- a/Makefile +++ b/Makefile @@ -851,6 +851,7 @@ TEST_BUILTINS_OBJS += test-read-cache.o TEST_BUILTINS_OBJS += test-read-graph.o TEST_BUILTINS_OBJS += test-read-midx.o TEST_BUILTINS_OBJS += test-ref-store.o +TEST_BUILTINS_OBJS += test-reflink.o TEST_BUILTINS_OBJS += test-reftable.o TEST_BUILTINS_OBJS += test-regex.o TEST_BUILTINS_OBJS += test-rot13-filter.o diff --git a/t/helper/meson.build b/t/helper/meson.build index 3235f10ab8aae1..af65605593f673 100644 --- a/t/helper/meson.build +++ b/t/helper/meson.build @@ -52,6 +52,7 @@ test_tool_sources = [ 'test-read-graph.c', 'test-read-midx.c', 'test-ref-store.c', + 'test-reflink.c', 'test-reftable.c', 'test-regex.c', 'test-repository.c', diff --git a/t/helper/test-reflink.c b/t/helper/test-reflink.c new file mode 100644 index 00000000000000..dc133e2f741edb --- /dev/null +++ b/t/helper/test-reflink.c @@ -0,0 +1,13 @@ +#include "test-tool.h" +#include "wrapper.h" + +int cmd__reflink(int argc, const char **argv) +{ + if (argc != 3) + usage("test-tool reflink "); + if (reflink_file(argv[1], argv[2], 0666)) { + fprintf(stderr, "reflink failed: %s\n", strerror(errno)); + return 1; + } + return 0; +} diff --git a/t/helper/test-tool.c b/t/helper/test-tool.c index b71a22b43bbc9e..8cd7004e9aec36 100644 --- a/t/helper/test-tool.c +++ b/t/helper/test-tool.c @@ -64,6 +64,7 @@ static struct test_cmd cmds[] = { { "read-graph", cmd__read_graph }, { "read-midx", cmd__read_midx }, { "ref-store", cmd__ref_store }, + { "reflink", cmd__reflink }, { "rot13-filter", cmd__rot13_filter }, { "regex", cmd__regex }, { "repository", cmd__repository }, diff --git a/t/helper/test-tool.h b/t/helper/test-tool.h index f2885b33d58aa8..01559591859e32 100644 --- a/t/helper/test-tool.h +++ b/t/helper/test-tool.h @@ -57,6 +57,7 @@ int cmd__read_cache(int argc, const char **argv); int cmd__read_graph(int argc, const char **argv); int cmd__read_midx(int argc, const char **argv); int cmd__ref_store(int argc, const char **argv); +int cmd__reflink(int argc, const char **argv); int cmd__rot13_filter(int argc, const char **argv); int cmd__regex(int argc, const char **argv); int cmd__repository(int argc, const char **argv); diff --git a/t/t2408-worktree-reflink.sh b/t/t2408-worktree-reflink.sh new file mode 100755 index 00000000000000..e59b1733f90728 --- /dev/null +++ b/t/t2408-worktree-reflink.sh @@ -0,0 +1,18 @@ +#!/bin/sh + +test_description='copy-on-write (reflink) population of new worktrees' + +. ./test-lib.sh + +test_expect_success REFLINK 'test-tool reflink clones file contents' ' + echo content >src && + test-tool reflink src dst && + test_cmp src dst +' + +test_expect_success 'test-tool reflink fails on missing source' ' + test_must_fail test-tool reflink does-not-exist dst2 && + test_path_is_missing dst2 +' + +test_done diff --git a/t/test-lib.sh b/t/test-lib.sh index d390c53ec1cfb9..87263a8215a07d 100644 --- a/t/test-lib.sh +++ b/t/test-lib.sh @@ -1804,6 +1804,12 @@ test_lazy_prereq SYMLINKS ' ln -s x y && test -h y ' +test_lazy_prereq REFLINK ' + # test whether the filesystem supports reflinks (FICLONE) + echo reflink >reflink-src && + test-tool reflink reflink-src reflink-dst +' + test_lazy_prereq SYMLINKS_WINDOWS ' # test whether symbolic links are enabled on Windows test_have_prereq MINGW && diff --git a/wrapper.c b/wrapper.c index 16f5a63fbb614a..132bc3e3ada6d6 100644 --- a/wrapper.c +++ b/wrapper.c @@ -11,6 +11,11 @@ #include "strbuf.h" #include "trace2.h" +#ifdef __linux__ +#include +#include +#endif + #ifdef HAVE_RTLGENRANDOM /* This is required to get access to RtlGenRandom. */ #define SystemFunction036 NTAPI SystemFunction036 @@ -909,3 +914,41 @@ void *xmmap(void *start, size_t length, die_errno(_("mmap failed%s"), mmap_os_err()); return ret; } + +#if defined(__linux__) && defined(FICLONE) +int reflink_file(const char *src, const char *dst, int mode) +{ + int src_fd, dst_fd, ret, saved_errno; + + src_fd = open(src, O_RDONLY); + if (src_fd < 0) + return -1; + + dst_fd = open(dst, O_WRONLY | O_CREAT | O_EXCL, mode); + if (dst_fd < 0) { + saved_errno = errno; + close(src_fd); + errno = saved_errno; + return -1; + } + + ret = ioctl(dst_fd, FICLONE, src_fd); + saved_errno = errno; + close(src_fd); + if (close(dst_fd) < 0 && !ret) { + ret = -1; + saved_errno = errno; + } + if (ret) + unlink(dst); + errno = saved_errno; + return ret ? -1 : 0; +} +#else +int reflink_file(const char *src UNUSED, const char *dst UNUSED, + int mode UNUSED) +{ + errno = ENOSYS; + return -1; +} +#endif diff --git a/wrapper.h b/wrapper.h index 15ac3bab6e9748..dc0b336b3b2c34 100644 --- a/wrapper.h +++ b/wrapper.h @@ -170,4 +170,13 @@ static inline unsigned log2u(uintmax_t sz) return l - 1; } +/* + * Create "dst" as a lightweight copy-on-write clone (reflink) of + * "src", creating it with the given mode. Returns 0 on success and + * -1 with errno set on failure, in which case no "dst" is left + * behind. Only supported on Linux (FICLONE); elsewhere it always + * fails with ENOSYS. + */ +int reflink_file(const char *src, const char *dst, int mode); + #endif /* WRAPPER_H */ From be7426c295230d383f1098ba0bea0e23d7095657 Mon Sep 17 00:00:00 2001 From: Walter Gray Date: Wed, 15 Jul 2026 10:38:02 -0700 Subject: [PATCH 2/7] entry: reflink files from a donor worktree when possible When the GIT_WORKTREE_REFLINK_SOURCE environment variable points at the primary worktree, write_entry() tries to create each regular file as a copy-on-write clone (reflink) of the donor's file instead of inflating the blob from the object database. A file is only cloned when the donor's index has the same path with the same object id and mode, the donor file is stat-clean (racily clean entries are conservatively treated as dirty, and assume- unchanged and fsmonitor validity are ignored so that a real stat comparison happens), no content conversion (CRLF, ident, filter, working-tree-encoding) applies to the path, and the donor file size matches the blob size. Anything else falls through to the regular write path, so behavior on filesystems or platforms without reflink support is unchanged. Successfully cloned files flow into the existing refresh_cache handling, so their stat information is recorded in the new index and they are never re-hashed. Two trace2 counters, reflink/attempts and reflink/hits, provide observability and a test hook. This addresses the dirty-donor concern raised against the earlier opt-in --reflink proposal: cloning is decided per file from the donor index, never by blindly copying the donor tree. Note that a donor whose index was written in the same second as its files (e.g. immediately after a clone or commit) is racily clean, and its entries are conservatively skipped rather than cloned; a later patch will make "git worktree add" refresh the donor index before spawning the checkout so that an aged donor becomes eligible. The tests therefore age the donor files and refresh the index to make the donor deterministically non-racy. Co-Authored-By: Claude Fable 5 Signed-off-by: Walter Gray --- entry.c | 127 +++++++++++++++++++++++++++++++++++- environment.h | 6 ++ t/t2408-worktree-reflink.sh | 78 ++++++++++++++++++++++ trace2.h | 4 ++ trace2/tr2_ctr.c | 11 ++++ 5 files changed, 225 insertions(+), 1 deletion(-) diff --git a/entry.c b/entry.c index 6b79884e3246d6..e8f737d5b6e532 100644 --- a/entry.c +++ b/entry.c @@ -1,6 +1,8 @@ #define USE_THE_REPOSITORY_VARIABLE #include "git-compat-util.h" +#include "abspath.h" +#include "convert.h" #include "odb.h" #include "odb/streaming.h" #include "dir.h" @@ -8,11 +10,16 @@ #include "gettext.h" #include "hex.h" #include "name-hash.h" +#include "path.h" +#include "read-cache-ll.h" +#include "repository.h" #include "sparse-index.h" #include "submodule.h" #include "symlinks.h" #include "progress.h" +#include "trace2.h" #include "fsmonitor.h" +#include "wrapper.h" #include "entry.h" #include "parallel-checkout.h" @@ -146,6 +153,119 @@ static int streaming_write_entry(const struct cache_entry *ce, char *path, return result; } +/* + * Copy-on-write population of a new worktree: when "git worktree add" + * points us at the primary worktree via GIT_WORKTREE_REFLINK_SOURCE, + * try to clone (reflink) files from it instead of writing blob + * contents from the object database. The donor's index tells us + * which of its files are known to be unmodified. + * + * Note that entries handled by parallel checkout (checkout.workers > + * 1) do not come through this path and are simply written normally. + */ +struct reflink_donor { + int state; /* -1 = not checked yet, 0 = disabled, 1 = active */ + const char *root; + struct index_state istate; +}; +static struct reflink_donor reflink_donor = { .state = -1 }; + +static int reflink_donor_active(void) +{ + if (reflink_donor.state < 0) { + const char *root; + char *index_path; + + reflink_donor.state = 0; + root = getenv(GIT_WORKTREE_REFLINK_SOURCE_ENVIRONMENT); + if (!root || !is_absolute_path(root)) + return 0; + + index_state_init(&reflink_donor.istate, the_repository); + index_path = repo_common_path(the_repository, "index"); + if (read_index_from(&reflink_donor.istate, index_path, + repo_get_common_dir(the_repository)) > 0) { + reflink_donor.root = xstrdup(root); + reflink_donor.state = 1; + } + free(index_path); + } + return reflink_donor.state; +} + +/* + * Try to create "path" as a reflink of the same file in the donor + * worktree. A zero return means the file is in place; any nonzero + * return means the caller must write it the normal way. + */ +static int try_reflink_entry(const struct cache_entry *ce, + const struct conv_attrs *ca, + const char *path) +{ + const struct cache_entry *dce; + struct stat st; + struct strbuf donor_path = STRBUF_INIT; + size_t blob_size; + int ret = -1; + + /* + * Only blobs whose worktree representation is identical to + * their content are eligible; anything that smudges is left + * to the regular code path. + */ + if (ca->drv || ca->ident || ca->working_tree_encoding || + ca->crlf_action != CRLF_BINARY) + return -1; + + if (!reflink_donor_active()) + return -1; + + dce = index_file_exists(&reflink_donor.istate, ce->name, + strlen(ce->name), 0); + if (!dce || ce_skip_worktree(dce) || + dce->ce_mode != ce->ce_mode || !oideq(&dce->oid, &ce->oid)) + return -1; + + strbuf_addf(&donor_path, "%s/%s", reflink_donor.root, ce->name); + + /* + * The donor file must be stat-clean with respect to the + * donor's index; racily clean entries are conservatively + * treated as dirty. For an eligible (conversion-free) entry + * the file size must then also match the blob size, which + * guards against a donor checkout that smudged the file under + * attribute rules we no longer see. + */ + if (lstat(donor_path.buf, &st) || !S_ISREG(st.st_mode)) + goto out; + if (ie_match_stat(&reflink_donor.istate, dce, &st, + CE_MATCH_IGNORE_VALID | CE_MATCH_IGNORE_FSMONITOR | + CE_MATCH_RACY_IS_DIRTY)) + goto out; + if (odb_read_object_info(the_repository->objects, &ce->oid, + &blob_size) < 0 || + (uintmax_t)st.st_size != (uintmax_t)blob_size) + goto out; + + trace2_counter_add(TRACE2_COUNTER_ID_REFLINK_ATTEMPTS, 1); + if (reflink_file(donor_path.buf, path, + (ce->ce_mode & 0100) ? 0777 : 0666)) { + /* + * These errnos mean the filesystem cannot reflink at + * all, so do not keep trying for every file. + */ + if (errno == EOPNOTSUPP || errno == ENOTTY || + errno == EXDEV || errno == ENOSYS) + reflink_donor.state = 0; + goto out; + } + trace2_counter_add(TRACE2_COUNTER_ID_REFLINK_HITS, 1); + ret = 0; +out: + strbuf_release(&donor_path); + return ret; +} + void enable_delayed_checkout(struct checkout *state) { if (!state->delayed_checkout) { @@ -300,7 +420,12 @@ 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 (!to_tempfile && !try_reflink_entry(ce, ca, path)) + goto finish; + + filter = get_stream_filter_ca(ca, &ce->oid); if (filter && !streaming_write_entry(ce, path, filter, state, to_tempfile, diff --git a/environment.h b/environment.h index 8aaedcfea36c47..68776715eabfc8 100644 --- a/environment.h +++ b/environment.h @@ -10,6 +10,12 @@ #define GIT_NAMESPACE_ENVIRONMENT "GIT_NAMESPACE" #define GIT_WORK_TREE_ENVIRONMENT "GIT_WORK_TREE" #define GIT_PREFIX_ENVIRONMENT "GIT_PREFIX" +/* + * Internal, set by "git worktree add" for the checkout it spawns: + * absolute path of the primary worktree, whose clean files may be + * reflinked instead of being written from the object database. + */ +#define GIT_WORKTREE_REFLINK_SOURCE_ENVIRONMENT "GIT_WORKTREE_REFLINK_SOURCE" #define DEFAULT_GIT_DIR_ENVIRONMENT ".git" #define DB_ENVIRONMENT "GIT_OBJECT_DIRECTORY" #define INDEX_ENVIRONMENT "GIT_INDEX_FILE" diff --git a/t/t2408-worktree-reflink.sh b/t/t2408-worktree-reflink.sh index e59b1733f90728..919366f64e38ff 100755 --- a/t/t2408-worktree-reflink.sh +++ b/t/t2408-worktree-reflink.sh @@ -15,4 +15,82 @@ test_expect_success 'test-tool reflink fails on missing source' ' test_path_is_missing dst2 ' +# Racily-clean donor entries (index written in the same second as the +# file's mtime) are conservatively skipped by the reflink logic, so a +# donor committed "just now" would never be cloned. Age the donor +# files into the past and refresh the index so that the recorded stat +# data is deterministically clean and non-racy. +test_expect_success REFLINK 'files are reflinked from donor during checkout' ' + test_commit file1 && + test-tool chmtime =-60 file1.t && + git update-index -q --refresh && + git worktree add --no-checkout wt1 && + GIT_TRACE2_EVENT="$PWD/trace1.json" \ + GIT_WORKTREE_REFLINK_SOURCE="$PWD" \ + git -C wt1 reset --hard && + test_cmp file1.t wt1/file1.t && + git -C wt1 status --porcelain >out && + test_must_be_empty out && + grep "\"category\":\"reflink\".*\"name\":\"hits\"" trace1.json +' + +test_expect_success REFLINK 'dirty donor file is not reflinked' ' + echo original >file2 && + git add file2 && + git commit -m file2 && + test-tool chmtime =-60 file2 && + git update-index -q --refresh && + echo dirtied >file2 && + git worktree add --no-checkout wt2 && + GIT_WORKTREE_REFLINK_SOURCE="$PWD" git -C wt2 reset --hard && + echo original >expect && + test_cmp expect wt2/file2 && + git checkout -- file2 +' + +test_expect_success REFLINK 'paths with conversion attributes are not reflinked' ' + git init attr-repo && + ( + cd attr-repo && + echo "file.txt text eol=crlf" >.gitattributes && + printf "one\r\ntwo\r\n" >file.txt && + git add . && + git commit -m one && + test-tool chmtime =-60 .gitattributes file.txt && + git update-index -q --refresh && + git worktree add --no-checkout wt && + GIT_TRACE2_EVENT="$PWD/trace.json" \ + GIT_WORKTREE_REFLINK_SOURCE="$PWD" \ + git -C wt reset --hard && + test_cmp file.txt wt/file.txt && + grep "\"category\":\"reflink\".*\"name\":\"hits\".*\"count\":1" trace.json + ) +' + +test_expect_success REFLINK,SYMLINKS 'symlinks are checked out correctly, not reflinked' ' + ln -s file1.t link1 && + git add link1 && + git commit -m link1 && + git worktree add --no-checkout wt3 && + GIT_WORKTREE_REFLINK_SOURCE="$PWD" git -C wt3 reset --hard && + test -h wt3/link1 +' + +test_expect_success REFLINK,POSIXPERM 'executable files keep their mode when reflinked' ' + test_write_lines "#!/bin/sh" "true" >exec.sh && + chmod +x exec.sh && + git add exec.sh && + git commit -m exec && + git worktree add --no-checkout wt4 && + GIT_WORKTREE_REFLINK_SOURCE="$PWD" git -C wt4 reset --hard && + test -x wt4/exec.sh +' + +test_expect_success 'bogus reflink source is harmless' ' + test_commit bogus-base && + git worktree add --no-checkout wt5 && + GIT_WORKTREE_REFLINK_SOURCE=/nonexistent git -C wt5 reset --hard && + test_cmp bogus-base.t wt5/bogus-base.t +' + test_done diff --git a/trace2.h b/trace2.h index e4f23784e4620f..8480d0ea42d407 100644 --- a/trace2.h +++ b/trace2.h @@ -560,6 +560,10 @@ enum trace2_counter_id { TRACE2_COUNTER_ID_FSYNC_WRITEOUT_ONLY, TRACE2_COUNTER_ID_FSYNC_HARDWARE_FLUSH, + /* counts reflink attempts/successes when populating a worktree */ + TRACE2_COUNTER_ID_REFLINK_ATTEMPTS, + TRACE2_COUNTER_ID_REFLINK_HITS, + /* Add additional counter definitions before here. */ TRACE2_NUMBER_OF_COUNTERS }; diff --git a/trace2/tr2_ctr.c b/trace2/tr2_ctr.c index ee17bfa86b401b..cb2f22e4578fd9 100644 --- a/trace2/tr2_ctr.c +++ b/trace2/tr2_ctr.c @@ -47,6 +47,17 @@ static struct tr2_counter_metadata tr2_counter_metadata[TRACE2_NUMBER_OF_COUNTER .want_per_thread_events = 0, }, + [TRACE2_COUNTER_ID_REFLINK_ATTEMPTS] = { + .category = "reflink", + .name = "attempts", + .want_per_thread_events = 0, + }, + [TRACE2_COUNTER_ID_REFLINK_HITS] = { + .category = "reflink", + .name = "hits", + .want_per_thread_events = 0, + }, + /* Add additional metadata before here. */ }; From b1eadd7dd15d43918e84d9be82ec96fafbcad7af Mon Sep 17 00:00:00 2001 From: Walter Gray Date: Wed, 15 Jul 2026 10:47:32 -0700 Subject: [PATCH 3/7] worktree: populate new worktrees with reflinks by default Teach "git worktree add" to point the checkout it spawns at the primary worktree via GIT_WORKTREE_REFLINK_SOURCE, so that eligible files are created as copy-on-write clones on filesystems that support it. The feature is transparent: there is no command-line option, unsupported filesystems and platforms silently fall back to a regular checkout, and the working tree contents are identical either way. Before pointing the child checkout at the primary worktree, refresh the primary worktree's index (git update-index -q --refresh, non-fatal on failure) so that files committed or modified moments ago are not left "racily clean" and therefore skipped by the reflink logic in entry.c. A single configuration variable, worktree.useReflink (default true), is provided for users who want physically independent copies (shared extents also share on-disk corruption) or need to work around filesystem problems. Co-Authored-By: Claude Fable 5 Signed-off-by: Walter Gray --- Documentation/config/worktree.adoc | 11 +++++++++ builtin/worktree.c | 28 ++++++++++++++++++++++ t/t2408-worktree-reflink.sh | 38 ++++++++++++++++++++++++++++++ 3 files changed, 77 insertions(+) diff --git a/Documentation/config/worktree.adoc b/Documentation/config/worktree.adoc index a248076ea50bd5..a510c664a45028 100644 --- a/Documentation/config/worktree.adoc +++ b/Documentation/config/worktree.adoc @@ -8,6 +8,17 @@ for the new branch. If no such match can be found, it falls back to creating a new branch from the current `HEAD`. +`worktree.useReflink`:: + When populating a new worktree, try to create working tree + files as lightweight copy-on-write clones (reflinks) of the + corresponding files in the main worktree whenever they are + known to have identical content. This shares disk blocks + between worktrees and avoids reading file contents from the + object database. It requires support from the filesystem + (e.g. Btrfs or XFS on Linux); where unsupported, `git worktree + add` silently falls back to a regular checkout. This currently + only has an effect on Linux. 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/builtin/worktree.c b/builtin/worktree.c index d21c43fde38b5e..f42cc299b01edc 100644 --- a/builtin/worktree.c +++ b/builtin/worktree.c @@ -458,6 +458,32 @@ static void setup_alternate_ref_dir(struct worktree *wt, const char *wt_git_path strbuf_release(&sb); } +static void push_reflink_source_env(struct strvec *child_env) +{ + struct worktree **worktrees; + int use_reflink = 1; + + repo_config_get_bool(the_repository, "worktree.usereflink", + &use_reflink); + if (!use_reflink) + return; + + worktrees = get_worktrees(); + if (worktrees[0] && !worktrees[0]->is_bare) { + struct child_process cp = CHILD_PROCESS_INIT; + + cp.git_cmd = 1; + cp.dir = worktrees[0]->path; + strvec_pushl(&cp.args, "update-index", "-q", "--refresh", NULL); + if (run_command(&cp)) + ; /* non-fatal: racy donor files just won't reflink */ + strvec_pushf(child_env, "%s=%s", + GIT_WORKTREE_REFLINK_SOURCE_ENVIRONMENT, + worktrees[0]->path); + } + free_worktrees(worktrees); +} + static int add_worktree(const char *path, const char *refname, const struct add_opts *opts) { @@ -584,6 +610,8 @@ static int add_worktree(const char *path, const char *refname, strvec_pushf(&child_env, "%s=%s", GIT_DIR_ENVIRONMENT, sb_git.buf); strvec_pushf(&child_env, "%s=%s", GIT_WORK_TREE_ENVIRONMENT, path); + if (opts->checkout && !opts->orphan) + push_reflink_source_env(&child_env); if (opts->orphan && (ret = make_worktree_orphan(refname, opts, &child_env))) diff --git a/t/t2408-worktree-reflink.sh b/t/t2408-worktree-reflink.sh index 919366f64e38ff..c7988863b0cd72 100755 --- a/t/t2408-worktree-reflink.sh +++ b/t/t2408-worktree-reflink.sh @@ -93,4 +93,42 @@ test_expect_success 'bogus reflink source is harmless' ' test_cmp bogus-base.t wt5/bogus-base.t ' +test_expect_success REFLINK 'git worktree add reflinks from the primary checkout' ' + test-tool chmtime =-60 file1.t && + GIT_TRACE2_EVENT="$PWD/trace-e2e.json" git worktree add wt-auto && + git -C wt-auto status --porcelain >out && + test_must_be_empty out && + test_cmp file1.t wt-auto/file1.t && + grep "\"category\":\"reflink\".*\"name\":\"hits\"" trace-e2e.json +' + +test_expect_success REFLINK 'worktree.useReflink=false disables reflinking' ' + GIT_TRACE2_EVENT="$PWD/trace-off.json" \ + git -c worktree.usereflink=false worktree add wt-off && + git -C wt-off status --porcelain >out && + test_must_be_empty out && + ! grep "\"category\":\"reflink\"" trace-off.json +' + +test_expect_success REFLINK 'worktree add from a linked worktree uses the primary donor' ' + test-tool chmtime =-60 file1.t && + GIT_TRACE2_EVENT="$PWD/trace-linked.json" \ + git -C wt-auto worktree add "$PWD/wt-from-linked" && + test_cmp file1.t wt-from-linked/file1.t && + grep "\"category\":\"reflink\".*\"name\":\"hits\"" trace-linked.json +' + +test_expect_success 'worktree add succeeds regardless of filesystem support' ' + git worktree add wt-plain && + git -C wt-plain status --porcelain >out && + test_must_be_empty out +' + +test_expect_success '--no-checkout skips donor refresh and reflink env' ' + GIT_TRACE2_EVENT="$PWD/trace-nc.json" \ + git worktree add --no-checkout wt-nc && + ! grep "update-index" trace-nc.json && + ! grep "\"category\":\"reflink\"" trace-nc.json +' + test_done From 111a8a99fe37b7a170d552d86007d18cd4fe2aa8 Mon Sep 17 00:00:00 2001 From: Walter Gray Date: Wed, 15 Jul 2026 11:16:12 -0700 Subject: [PATCH 4/7] entry: also reflink files with input-style CRLF conversion try_reflink_entry() only reflinked a donor file when its path carried no content conversion at all (ca->crlf_action == CRLF_BINARY). In git.git's own tree that disqualifies the great majority of files, because its .gitattributes marks them "* text eol=lf", which convert_attrs() resolves to CRLF_TEXT_INPUT -- so a "git worktree add" reflinked only ~32% of the tree and kept paying a full copy for the rest. The checkout-direction (smudge) conversion for the input-style CRLF actions is the identity: crlf_to_worktree() returns early unless output_eol() is EOL_CRLF, which holds only for the "*_CRLF" actions, not for CRLF_TEXT_INPUT or CRLF_AUTO_INPUT. An LF blob therefore checks out byte-for-byte as its own content, so the worktree bytes of a fresh checkout equal the blob bytes. The clean direction only ever removes CR bytes (it never adds or substitutes), so a stat-clean donor whose on-disk size equals the blob size cannot have diverged from the blob -- the existing size==blob backstop makes a byte-divergent donor impossible. Admit CRLF_BINARY, CRLF_TEXT_INPUT, and CRLF_AUTO_INPUT via a small helper; ident, custom drv filters, and working-tree-encoding remain excluded because their smudge is not the identity. On git.git this lifts reflink eligibility from 1546/4779 (~32%) to 4773/4780 (~99.85%) of tracked files, cutting a "worktree add"'s exclusive (unshared) disk usage on btrfs from 17.33 MiB to ~12 KiB with no measurable time cost. Signed-off-by: Walter Gray Co-Authored-By: Claude Fable 5 --- entry.c | 30 +++++++++++++++++++-- t/t2408-worktree-reflink.sh | 52 +++++++++++++++++++++++++++++++++++++ 2 files changed, 80 insertions(+), 2 deletions(-) diff --git a/entry.c b/entry.c index e8f737d5b6e532..45d9c66a1e606f 100644 --- a/entry.c +++ b/entry.c @@ -193,6 +193,26 @@ static int reflink_donor_active(void) return reflink_donor.state; } +/* + * The checkout-direction (smudge) CRLF conversion is the identity for + * these actions: crlf_to_worktree() bails out immediately unless the + * action's output_eol() is EOL_CRLF, which is true only for the + * "*_CRLF" actions. For CRLF_BINARY and the input-style actions a + * blob therefore checks out byte-for-byte as its own content, so the + * worktree bytes of a fresh checkout equal the blob bytes. + */ +static int crlf_action_is_identity_smudge(enum convert_crlf_action action) +{ + switch (action) { + case CRLF_BINARY: + case CRLF_TEXT_INPUT: + case CRLF_AUTO_INPUT: + return 1; + default: + return 0; + } +} + /* * Try to create "path" as a reflink of the same file in the donor * worktree. A zero return means the file is in place; any nonzero @@ -211,10 +231,16 @@ static int try_reflink_entry(const struct cache_entry *ce, /* * Only blobs whose worktree representation is identical to * their content are eligible; anything that smudges is left - * to the regular code path. + * to the regular code path. Input-style CRLF actions are + * admitted because their smudge is the identity (see + * crlf_action_is_identity_smudge()); the clean direction only + * ever removes CR bytes, so a donor whose on-disk size equals + * the blob size cannot have diverged from it -- the size gate + * below is the backstop that makes byte-divergent donors + * impossible. */ if (ca->drv || ca->ident || ca->working_tree_encoding || - ca->crlf_action != CRLF_BINARY) + !crlf_action_is_identity_smudge(ca->crlf_action)) return -1; if (!reflink_donor_active()) diff --git a/t/t2408-worktree-reflink.sh b/t/t2408-worktree-reflink.sh index c7988863b0cd72..327e47429b81c3 100755 --- a/t/t2408-worktree-reflink.sh +++ b/t/t2408-worktree-reflink.sh @@ -131,4 +131,56 @@ test_expect_success '--no-checkout skips donor refresh and reflink env' ' ! grep "\"category\":\"reflink\"" trace-nc.json ' +# "eol=lf" resolves to an input-style CRLF action (CRLF_TEXT_INPUT) whose +# checkout-direction (smudge) conversion is the identity, so an LF blob +# checks out byte-for-byte as its own contents. Such files are eligible +# for reflink. Here both .gitattributes (no attribute matches its own +# name -> CRLF_BINARY) and file.txt (eol=lf -> CRLF_TEXT_INPUT) are +# reflinked, so the donor yields two hits. +test_expect_success REFLINK 'input-style CRLF (eol=lf) files are reflinked' ' + git init eol-lf-repo && + ( + cd eol-lf-repo && + echo "*.txt text eol=lf" >.gitattributes && + printf "one\ntwo\n" >file.txt && + git add . && + git commit -m one && + test-tool chmtime =-60 .gitattributes file.txt && + git update-index -q --refresh && + git worktree add --no-checkout wt && + GIT_TRACE2_EVENT="$PWD/trace.json" \ + GIT_WORKTREE_REFLINK_SOURCE="$PWD" \ + git -C wt reset --hard && + test_cmp file.txt wt/file.txt && + grep "\"category\":\"reflink\".*\"name\":\"hits\".*\"count\":2" trace.json + ) +' + +# Guard the belt-and-braces size gate: the working file holds CRLF bytes, +# but "eol=lf" cleans them to an LF blob, so git considers the file +# content-clean. The index records the on-disk size (10 bytes, CRLF) +# while the blob is 8 bytes (LF), so the donor entry is stat-clean yet its +# on-disk bytes diverge from the blob. The size==blob backstop must +# reject it, and the new worktree must receive the LF blob, never the +# donor's CRLF bytes. Only .gitattributes is reflinked -> exactly one hit. +test_expect_success REFLINK 'eol=lf donor with divergent worktree bytes is not reflinked' ' + git init eol-lf-crlf-repo && + ( + cd eol-lf-crlf-repo && + echo "*.txt text eol=lf" >.gitattributes && + printf "one\r\ntwo\r\n" >file.txt && + git add . && + git commit -m one && + test-tool chmtime =-60 .gitattributes file.txt && + git update-index -q --refresh && + git worktree add --no-checkout wt && + GIT_TRACE2_EVENT="$PWD/trace.json" \ + GIT_WORKTREE_REFLINK_SOURCE="$PWD" \ + git -C wt reset --hard && + printf "one\ntwo\n" >expect && + test_cmp expect wt/file.txt && + grep "\"category\":\"reflink\".*\"name\":\"hits\".*\"count\":1" trace.json + ) +' + test_done From 9722b2f5aadff6a6321696a09cee8e2ddcec8ea1 Mon Sep 17 00:00:00 2001 From: Walter Gray Date: Wed, 15 Jul 2026 11:31:09 -0700 Subject: [PATCH 5/7] worktree: mark reflink source env var repo-local Add GIT_WORKTREE_REFLINK_SOURCE_ENVIRONMENT to local_repo_env[] in environment.c so it is cleared when git crosses a repository boundary (e.g. into a submodule), matching how other internal repo-local vars are handled; the `worktree add` checkout child still receives it via explicit child_env, unaffected by this change. Also replace the empty-statement `if (run_command(&cp)) ;` idiom in builtin/worktree.c with a plain statement plus a leading comment. Along the way, fix mislabeled test suite names in the benchmark regression-sweep table (t2401/t2402/t2404/t2405). Co-Authored-By: Claude Fable 5 Signed-off-by: Walter Gray --- builtin/worktree.c | 4 ++-- environment.c | 2 ++ 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/builtin/worktree.c b/builtin/worktree.c index f42cc299b01edc..169810a275004e 100644 --- a/builtin/worktree.c +++ b/builtin/worktree.c @@ -475,8 +475,8 @@ static void push_reflink_source_env(struct strvec *child_env) cp.git_cmd = 1; cp.dir = worktrees[0]->path; strvec_pushl(&cp.args, "update-index", "-q", "--refresh", NULL); - if (run_command(&cp)) - ; /* non-fatal: racy donor files just won't reflink */ + /* non-fatal: racy donor files just won't reflink */ + run_command(&cp); strvec_pushf(child_env, "%s=%s", GIT_WORKTREE_REFLINK_SOURCE_ENVIRONMENT, worktrees[0]->path); diff --git a/environment.c b/environment.c index 8f0c1c4f250193..e3ced3711bec0a 100644 --- a/environment.c +++ b/environment.c @@ -101,6 +101,8 @@ const char * const local_repo_env[] = { GIT_DIR_ENVIRONMENT, GIT_WORK_TREE_ENVIRONMENT, GIT_IMPLICIT_WORK_TREE_ENVIRONMENT, + /* internal; set by "git worktree add" for its checkout child */ + GIT_WORKTREE_REFLINK_SOURCE_ENVIRONMENT, GRAFT_ENVIRONMENT, INDEX_ENVIRONMENT, NO_REPLACE_OBJECTS_ENVIRONMENT, From 26f1eaef20346703e3c4624dd3b07176049797ee Mon Sep 17 00:00:00 2001 From: Walter Gray Date: Wed, 15 Jul 2026 11:40:07 -0700 Subject: [PATCH 6/7] worktree: isolate donor refresh from inherited repo environment push_reflink_source_env() spawns "git update-index -q --refresh" against the primary worktree's path (cp.dir), but did not clear the repo-local environment variables (GIT_DIR, GIT_WORK_TREE, etc.) that the parent process may have inherited or been given explicitly. When "worktree add" is invoked as "git -C worktree add ...", the parent process has GIT_DIR exported pointing at the linked worktree's gitdir (.git/worktrees/). The refresh child inherits that GIT_DIR and, ignoring cp.dir, ends up refreshing the linked worktree's index instead of the primary/donor's. The donor index is therefore never refreshed, so every donor file fails the stat-clean gate in the reflink checkout path and reflinking silently never fires for worktrees created from a linked worktree. This same stale-donor-index bug is the root cause of the intermittent failure of t2408 test 11 ("worktree add from a linked worktree uses the primary donor"): the test only passed when consecutive tests happened to land within the same wall-clock second, which made the stale cached stat data spuriously match the donor file's real stat data. Fix this by pushing local_repo_env onto the child's environment before running it, the standard idiom (see trailer.c and odb.c) for making a spawned git process rediscover its repository from cwd/-C rather than inheriting the parent's repo-local environment. Co-Authored-By: Claude Fable 5 Signed-off-by: Walter Gray --- builtin/worktree.c | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/builtin/worktree.c b/builtin/worktree.c index 169810a275004e..ced74bb55260e3 100644 --- a/builtin/worktree.c +++ b/builtin/worktree.c @@ -474,6 +474,13 @@ static void push_reflink_source_env(struct strvec *child_env) cp.git_cmd = 1; cp.dir = worktrees[0]->path; + /* + * Clear any repo-local vars (e.g. GIT_DIR, GIT_WORK_TREE) + * inherited from the calling process so the child rediscovers + * the repository from cp.dir instead of reusing whichever + * worktree we happen to have been invoked from. + */ + strvec_pushv(&cp.env, (const char **)local_repo_env); strvec_pushl(&cp.args, "update-index", "-q", "--refresh", NULL); /* non-fatal: racy donor files just won't reflink */ run_command(&cp); From d57600499bc31cf9e05ac1063c810e93849e2ad2 Mon Sep 17 00:00:00 2001 From: Walter Gray Date: Wed, 15 Jul 2026 18:42:29 -0700 Subject: [PATCH 7/7] t2408: cover reflink donor edge cases Close five test-coverage gaps in the reflink eligibility gate (try_reflink_entry() in entry.c) that were not yet pinned by any test: - First FICLONE failure disables further reflink attempts for the rest of the process (attempts stays at 1 across 3 eligible files), gated !REFLINK since it requires a filesystem where FICLONE genuinely fails. - A donor entry marked skip-worktree is excluded, even though the target worktree's own fresh entry has no such flag. - A donor entry with CE_VALID (assume-unchanged) and stale, same-size on-disk content is rejected because CE_MATCH_IGNORE_VALID forces a real stat compare instead of trusting the assume-unchanged bit; using same-size replacement content is deliberate, since a different-size replacement would also be rejected by the independent size==blob backstop and wouldn't isolate the CE_MATCH_IGNORE_VALID behavior (verified by toggling the flag locally and observing this test flip). - A donor index entry whose mode was flipped via "update-index --chmod" without touching the tree is rejected by the ce_mode comparison before any stat is even done. - Gitlink entries, which never reach try_reflink_entry() at all (it is only consulted for S_IFREG blobs), check out identically with or without an active reflink donor. No production code changes; all 20 t2408 tests pass on both btrfs (--root, 3x for stability) and ext4 (plain), with REFLINK/!REFLINK prereqs gating the right subset on each filesystem. Co-Authored-By: Claude Fable 5 Signed-off-by: Walter Gray --- t/t2408-worktree-reflink.sh | 127 ++++++++++++++++++++++++++++++++++++ 1 file changed, 127 insertions(+) diff --git a/t/t2408-worktree-reflink.sh b/t/t2408-worktree-reflink.sh index 327e47429b81c3..cc4f643554ff00 100755 --- a/t/t2408-worktree-reflink.sh +++ b/t/t2408-worktree-reflink.sh @@ -183,4 +183,131 @@ test_expect_success REFLINK 'eol=lf donor with divergent worktree bytes is not r ) ' +# On a filesystem where FICLONE is not supported at all (e.g. ext4), the +# first reflink attempt fails with EOPNOTSUPP/ENOTTY/ENOSYS/EXDEV and +# reflink_donor.state latches to "disabled" for the rest of the process, +# so no further attempts are made even though more eligible donor files +# remain. This only makes sense on a filesystem where FICLONE truly +# fails, hence the !REFLINK gate: the REFLINK prereq itself proves +# FICLONE works here, which would defeat the point of this test. +test_expect_success !REFLINK 'first FICLONE failure disables further attempts' ' + git init reflink-disable-repo && + ( + cd reflink-disable-repo && + test_commit fileA && + test_commit fileB && + test_commit fileC && + test-tool chmtime =-60 fileA.t fileB.t fileC.t && + git update-index -q --refresh && + git worktree add --no-checkout wt && + GIT_TRACE2_EVENT="$PWD/trace.json" \ + GIT_WORKTREE_REFLINK_SOURCE="$PWD" \ + git -C wt reset --hard && + test_cmp fileA.t wt/fileA.t && + test_cmp fileB.t wt/fileB.t && + test_cmp fileC.t wt/fileC.t && + grep "\"category\":\"reflink\".*\"name\":\"attempts\".*\"count\":1" trace.json && + ! grep "\"category\":\"reflink\".*\"name\":\"hits\"" trace.json + ) +' + +test_expect_success REFLINK 'skip-worktree donor entries are not reflinked' ' + git init skip-worktree-repo && + ( + cd skip-worktree-repo && + test_commit fileA && + test_commit fileB && + test-tool chmtime =-60 fileA.t fileB.t && + git update-index -q --refresh && + git update-index --skip-worktree fileB.t && + git worktree add --no-checkout wt && + GIT_TRACE2_EVENT="$PWD/trace.json" \ + GIT_WORKTREE_REFLINK_SOURCE="$PWD" \ + git -C wt reset --hard && + test_cmp fileA.t wt/fileA.t && + test_cmp fileB.t wt/fileB.t && + grep "\"category\":\"reflink\".*\"name\":\"hits\".*\"count\":1" trace.json + ) +' + +# The donor's entry carries CE_VALID (assume-unchanged), but its on-disk +# content was changed without telling the index. The replacement text is +# deliberately the same size as the original blob: that neutralizes the +# independent size==blob backstop (which would reject a differently-sized +# replacement on its own and mask whatever ie_match_stat() decided), so +# the only thing standing between this stale donor and a bad reflink is +# passing CE_MATCH_IGNORE_VALID to force a real stat compare instead of +# trusting the assume-unchanged bit. +test_expect_success REFLINK 'assume-unchanged donor with stale content is not reflinked' ' + git init assume-unchanged-repo && + ( + cd assume-unchanged-repo && + printf "hello world\n" >file.t && + git add file.t && + git commit -m file && + test-tool chmtime =-60 file.t && + git update-index -q --refresh && + git update-index --assume-unchanged file.t && + printf "HELLO WORLD\n" >file.t && + git worktree add --no-checkout wt && + GIT_TRACE2_EVENT="$PWD/trace.json" \ + GIT_WORKTREE_REFLINK_SOURCE="$PWD" \ + git -C wt reset --hard && + printf "hello world\n" >expect && + test_cmp expect wt/file.t && + ! grep "\"category\":\"reflink\"" trace.json + ) +' + +# "update-index --chmod=+x" only flips the mode bit recorded in the +# index; it does not touch the file on disk. That leaves the donor's +# index entry at 100755 while the committed tree (and the new worktree's +# own index, built fresh from that tree) still has 100644, so the +# ce_mode comparison in try_reflink_entry() must reject the donor before +# ever looking at its on-disk stat data. +test_expect_success REFLINK,POSIXPERM 'donor index mode differing from target tree is not reflinked' ' + git init mode-mismatch-repo && + ( + cd mode-mismatch-repo && + test_write_lines "#!/bin/sh" "true" >file.sh && + git add file.sh && + git commit -m file && + test-tool chmtime =-60 file.sh && + git update-index -q --refresh && + git update-index --chmod=+x file.sh && + git worktree add --no-checkout wt && + GIT_TRACE2_EVENT="$PWD/trace.json" \ + GIT_WORKTREE_REFLINK_SOURCE="$PWD" \ + git -C wt reset --hard && + test_cmp file.sh wt/file.sh && + ! test -x wt/file.sh && + ! grep "\"category\":\"reflink\"" trace.json + ) +' + +# Gitlink entries never reach try_reflink_entry() (it is only consulted +# for S_IFREG blobs), so they are unaffected by a reflink donor; this +# guards against a regression that made checkout mishandle gitlinks +# whenever a donor is active, by comparing against a control worktree +# with reflinking turned off via worktree.usereflink=false. +test_expect_success REFLINK 'gitlink entries are checked out correctly with reflink donor' ' + git init gitlink-repo && + ( + cd gitlink-repo && + git init inner && + test_commit -C inner inner-file && + inner_head=$(git -C inner rev-parse HEAD) && + git update-index --add --cacheinfo 160000,$inner_head,inner && + test_commit regular && + test-tool chmtime =-60 regular.t && + git update-index -q --refresh && + GIT_TRACE2_EVENT="$PWD/trace.json" git worktree add wt && + git -c worktree.usereflink=false worktree add wt-control && + test_cmp regular.t wt/regular.t && + test_path_is_dir wt/inner && + test_path_is_dir wt-control/inner && + grep "\"category\":\"reflink\".*\"name\":\"hits\".*\"count\":1" trace.json + ) +' + test_done