From: "Jason Newton via GitGitGadget" <gitgitgadget@gmail.com>
To: git@vger.kernel.org
Cc: "brian m. carlson" <sandals@crustytoothpaste.net>,
Jason Newton <nevion@gmail.com>
Subject: [PATCH v2 0/2] worktree: copy-on-write creation and ephemeral branches
Date: Tue, 20 Oct 2026 16:04:07 +0000 [thread overview]
Message-ID: <pull.2317.v2.git.git.1792512249.gitgitgadget@gmail.com> (raw)
In-Reply-To: <pull.2317.git.git.1780685368.gitgitgadget@gmail.com>
When many worktrees share one repository -- for example a fleet of agents
each needing an isolated checkout -- "git worktree add" is costly at scale.
Objects are shared via the common directory, but the working tree is not:
each add rewrites every tracked file, so N worktrees cost N full checkouts
of disk and I/O. A branch created only to back one of those worktrees is
also left behind when the worktree is removed.
Patch 1 adds "git worktree add --reflink". On a copy-on-write filesystem it
populates the new worktree by reflinking the current worktree's files and
index, then "git reset --hard" rewrites only the paths that differ from .
reflink_file() in copy.c uses FICLONE (Linux), clonefile() (macOS), and ReFS
block cloning (FSCTL_DUPLICATE_EXTENTS_TO_FILE, as Git LFS does). Elsewhere
it is probed up front and falls back to a normal checkout. worktree.reflink
(true/false/auto) sets the default; --no-reflink overrides.
Uncommitted edits to tracked files in the source do not leak into the new
worktree. The copied index is refreshed before reset, and because every
reflinked file is a new inode the refresh re-validates by content. Only
untracked files are carried over. The option stays opt-in for that reason:
it copies the source's untracked and ignored files, which a plain checkout
does not.
For many worktrees at one commit, the series does not share a branch.
git-worktree(1) shows a single update-ref transaction that creates a ref per
worktree (or a detached HEAD). A shared branch still lets a stale index in
one worktree silently revert a commit made in another.
Patch 2 treats a branch that "git worktree add " created itself as owned by
that worktree. "git worktree remove" deletes it. Branches named with -b/-B,
or that already existed, are left alone. --keep-branch keeps an owned
branch.
Changes since v1:
* Dropped the patch that allowed one branch to be checked out in several
worktrees. Documented the per-ref / detached-HEAD pattern instead (brian
m. carlson, Junio).
* Explained, in the commit message and the manual, why a dirty source
worktree does not leak tracked content (brian m. carlson).
* Added the ReFS block-cloning arm (brian m. carlson).
* Noted overlayfs as a supported setup when the lower filesystem supports
reflinks (brian m. carlson).
* Kept --reflink opt-in. A whole-tree reflink is not invisible: it carries
untracked files, and cloning every file can be slower than checkout when
the files are small (Junio; qub1n on the ReFS work).
* Rebased onto 47fdf2208c ("The 7th batch"). reflink_file() now takes a
repository argument, matching copy_file().
* Added ephemeral-branch reaping for the auto-named branch that "worktree
add " creates.
Jason Newton (2):
worktree: add --reflink for copy-on-write worktree creation
worktree: reap ephemeral branches created by "worktree add"
Documentation/config/worktree.adoc | 10 +
Documentation/git-worktree.adoc | 71 ++++++-
builtin/branch.c | 28 +++
builtin/worktree.c | 301 ++++++++++++++++++++++++++++-
copy.c | 190 ++++++++++++++++++
copy.h | 14 ++
t/t2400-worktree-add.sh | 143 ++++++++++++++
t/t3200-branch.sh | 2 +-
t/t3400-rebase.sh | 2 +-
t/t5516-fetch-push.sh | 2 +-
10 files changed, 754 insertions(+), 9 deletions(-)
base-commit: 47fdf2208c5b94c8cc01c106b8f7c85efb91c926
Published-As: https://github.com/gitgitgadget/git/releases/tag/pr-git-2317%2Fnevion%2Fworktree-reflink-cow-v2
Fetch-It-Via: git fetch https://github.com/gitgitgadget/git pr-git-2317/nevion/worktree-reflink-cow-v2
Pull-Request: https://github.com/git/git/pull/2317
Range-diff vs v1:
1: 7dbd9ca94f ! 1: 83841020f4 worktree: add --reflink for copy-on-write worktree creation
@@ Commit message
via the common directory, but the working tree is not: N worktrees
mean N full checkouts on disk and N times the file I/O.
- Add a "--reflink" option that, on copy-on-write filesystems, populates
+ Add a "--reflink" option that, on a copy-on-write filesystem, populates
the new worktree by reflinking the current worktree's files and index
instead. The subsequent "git reset --hard" then only rewrites the
paths that actually differ between the current worktree and
<commit-ish>; everything else (including untracked files such as build
- outputs) keeps sharing storage with the source until modified. Because
- the cloned index still carries the source files' stat data, it is
- refreshed against the reflinked files first so that reset recognizes
- the unchanged paths as up to date and leaves them sharing extents
- rather than rewriting them.
+ outputs) keeps sharing storage with the source until modified.
- The clones are made by a new reflink_file() helper in copy.c, which
- uses the FICLONE ioctl on Linux and clonefile() on macOS and reports
- an error otherwise so callers fall back to a normal copy. Support is
- probed up front; when unavailable -- including on filesystems without
- copy-on-write and on platforms such as Windows that lack a reflink
- primitive -- "--reflink" transparently falls back to an ordinary
- checkout, so the worst case is no slower than today rather than a
- byte-for-byte copy of the source tree. The directory walk skips the
- new worktree itself when it lives inside the source one, and preserves
- symlinks and modes.
+ A new reflink_file() helper in copy.c clones every regular file. There
+ is no size cutoff.
+
+ On Linux it uses the FICLONE ioctl. That is the primitive btrfs, XFS
+ and bcachefs implement, and the one overlayfs forwards when the lower
+ filesystem can clone. This path has been tested on btrfs.
+
+ On macOS it uses clonefile(), the APFS primitive. That path is
+ implemented and has not been run here.
+
+ On Windows it uses ReFS block cloning,
+ FSCTL_DUPLICATE_EXTENTS_TO_FILE, as Git LFS does. The destination is
+ sized to the source, extents are cloned in chunks of at most 1GiB, and
+ the tail is rounded up to a cluster (64K, then 4K) and cloned; Windows
+ ignores the range past EOF. The tail is not byte-copied, and small
+ files are not skipped. That path is implemented and has not been run
+ on ReFS. A volume that rejects the control code, including NTFS, fails
+ the probe.
+
+ Anywhere else there is no reflink primitive. Support is probed up front
+ by cloning a one-byte file, and where that fails "--reflink" falls back
+ to an ordinary checkout instead of byte-copying the source tree. The
+ directory walk skips the new worktree itself when it lives inside the
+ source one, and preserves symlinks and modes.
+
+ Uncommitted changes in the source do not leak into the new worktree.
+ After reflinking the source's files we copy its index and run
+ "update-index --refresh" before "reset --hard": because every reflinked
+ file is a fresh inode, the cached stat never matches, so the refresh is
+ forced to re-validate by content rather than by stat. A tracked path is
+ therefore only left sharing extents when its content equals the indexed
+ object; any path whose content differs from <commit-ish> -- which
+ includes every uncommitted modification in the source -- is rewritten
+ by the reset. The new worktree is thus always a faithful checkout of
+ <commit-ish> for tracked paths, and only untracked files are carried
+ over.
The behavior can be made the default with the worktree.reflink
configuration ("true", "false" or "auto", the last suppressing the
unsupported-filesystem warning), and turned off per-invocation with
--no-reflink. A configured default degrades quietly in modes that
cannot reflink (--orphan, --no-checkout) instead of erroring, so
- enabling it never breaks those commands. The checkout step continues
- to honor checkout.workers, so parallel checkout composes with
- --reflink for the paths that do need rewriting.
+ enabling it never breaks those commands. The checkout step continues to
+ honor checkout.workers, so parallel checkout composes with --reflink for
+ the paths that do need rewriting.
+
+ To check out many worktrees at the same commit safely, give each its
+ own ref (or a detached HEAD) rather than sharing one branch; a
+ git-worktree(1) example shows the one-shot "update-ref" transaction
+ that pairs with --reflink.
Signed-off-by: Jason Newton <nevion@gmail.com>
@@ Documentation/git-worktree.adoc: To remove a locked worktree, specify `--force`
+ worktrees from the same base. `--no-reflink` forces an ordinary
+ checkout, overriding the `worktree.reflink` configuration.
++
-+Reflinks require a copy-on-write filesystem (for example btrfs, XFS,
-+bcachefs or ZFS on Linux, or APFS on macOS). On filesystems or platforms
-+that do not support reflinks, `--reflink` transparently falls back to an
-+ordinary checkout. Because the source worktree's untracked and ignored
-+files are cloned as well, only use `--reflink` when that is acceptable.
++Reflinks require a copy-on-write filesystem: for example btrfs, XFS,
++bcachefs or ZFS on Linux (and overlayfs on top of one of these, as used by
++containers), APFS on macOS, or ReFS on Windows. On a filesystem that does
++not support reflinks, `--reflink` transparently falls back to an ordinary
++checkout. Because the source worktree's untracked and ignored files are
++cloned as well, only use `--reflink` when that is acceptable.
++
+This option cannot be combined with `--no-checkout` or `--orphan`. It can
+be enabled by default with the `worktree.reflink` configuration; see
@@ Documentation/git-worktree.adoc: To remove a locked worktree, specify `--force`
`--guess-remote`::
`--no-guess-remote`::
With `worktree add <path>`, without _<commit-ish>_, instead
+@@ Documentation/git-worktree.adoc: $ popd
+ $ git worktree remove ../temp
+ ------------
+
++To run several independent worktrees from the same starting commit -- for
++example a pool of automated agents -- give each one its own branch rather than
++sharing a single branch between them. A shared branch is unsafe here: if one
++worktree commits and another then commits from its now-stale index, the second
++commit silently drops the first one's changes. Create the branches in a single
++ref transaction and check each out in its own (optionally reflinked) worktree:
++
++------------
++$ oid=$(git rev-parse HEAD)
++$ printf 'create refs/heads/agent-%s %s\n' 1 "$oid" 2 "$oid" 3 "$oid" |
++ git update-ref --stdin
++$ for n in 1 2 3; do git worktree add --reflink ../agent-$n agent-$n; done
++------------
++
++The single linkgit:git-update-ref[1] transaction is cheap and is especially
++efficient with the `reftable` backend. If a worktree does not need to advance a
++branch at all, use a detached `HEAD` (`git worktree add --detach`) instead.
++
+ CONFIGURATION
+ -------------
+
## builtin/worktree.c ##
@@
@@ builtin/worktree.c: worktree_copy_cleanup:
+/*
+ * Probe whether the filesystem backing "dir" supports reflinks. We do this
-+ * once up front so that, on filesystems without copy-on-write support (or on
-+ * platforms such as Windows that lack a reflink primitive entirely), we can
++ * once up front so that, on filesystems without copy-on-write support (and on
++ * platforms that lack a reflink primitive entirely), we can
+ * fall back to a normal checkout instead of byte-copying the whole source
+ * working tree -- which would include untracked files and be slower than the
+ * checkout we are trying to avoid.
@@ builtin/worktree.c: worktree_copy_cleanup:
+ if (fd >= 0) {
+ write_in_full(fd, "x", 1);
+ close(fd);
-+ if (!reflink_file(dst.buf, src.buf, 0600))
++ if (!reflink_file(the_repository, dst.buf, src.buf, 0600))
+ ok = 1;
+ unlink(dst.buf);
+ }
@@ builtin/worktree.c: worktree_copy_cleanup:
+ */
+static int reflink_or_copy(const char *dst, const char *src, int mode)
+{
-+ if (!reflink_file(dst, src, mode))
++ if (!reflink_file(the_repository, dst, src, mode))
+ return 0;
-+ return copy_file(dst, src, mode);
++ return copy_file(the_repository, dst, src, mode);
+}
+
+/*
@@ copy.c
+#elif defined(__APPLE__)
+#include <sys/attr.h>
+#include <sys/clonefile.h>
++#elif defined(GIT_WINDOWS_NATIVE)
++#include <winioctl.h>
++/*
++ * Block cloning on ReFS uses FSCTL_DUPLICATE_EXTENTS_TO_FILE. Older Windows
++ * SDK headers may lack the control code and its payload struct, so define
++ * them ourselves when missing (the values are part of the stable Win32 API).
++ */
++#ifndef FSCTL_DUPLICATE_EXTENTS_TO_FILE
++#define FSCTL_DUPLICATE_EXTENTS_TO_FILE \
++ CTL_CODE(FILE_DEVICE_FILE_SYSTEM, 209, METHOD_BUFFERED, FILE_WRITE_DATA)
++typedef struct _DUPLICATE_EXTENTS_DATA {
++ HANDLE FileHandle;
++ LARGE_INTEGER SourceFileOffset;
++ LARGE_INTEGER TargetFileOffset;
++ LARGE_INTEGER ByteCount;
++} DUPLICATE_EXTENTS_DATA, *PDUPLICATE_EXTENTS_DATA;
++#endif
+#endif
+
int copy_fd(int ifd, int ofd)
{
while (1) {
-@@ copy.c: int copy_file_with_time(const char *dst, const char *src, int mode)
+@@ copy.c: int copy_file_with_time(struct repository *repo,
return copy_times(dst, src);
return status;
}
+
-+int reflink_file(const char *dst, const char *src, int mode)
++#ifdef GIT_WINDOWS_NATIVE
++/*
++ * Clone [offset, offset+count) of "src" onto "dst" via ReFS block cloning.
++ * "count" is rounded up to a cluster boundary by the caller; a range that
++ * runs past the source's end of file is harmless and ignored by Windows.
++ */
++static int clone_extent_windows(HANDLE dst, HANDLE src,
++ uint64_t offset, uint64_t count)
++{
++ DUPLICATE_EXTENTS_DATA dup;
++ DWORD bytes;
++
++ dup.FileHandle = src;
++ dup.SourceFileOffset.QuadPart = offset;
++ dup.TargetFileOffset.QuadPart = offset;
++ dup.ByteCount.QuadPart = count;
++ if (!DeviceIoControl(dst, FSCTL_DUPLICATE_EXTENTS_TO_FILE,
++ &dup, sizeof(dup), NULL, 0, &bytes, NULL))
++ return -1;
++ return 0;
++}
++#endif
++
++int reflink_file(struct repository *repo,
++ const char *dst, const char *src, int mode)
+{
+#if defined(__APPLE__)
+ /*
@@ copy.c: int copy_file_with_time(const char *dst, const char *src, int mode)
+ (void)mode;
+ if (clonefile(src, dst, 0) < 0)
+ return -1;
-+ if (adjust_shared_perm(the_repository, dst))
++ if (adjust_shared_perm(repo, dst))
+ return -1;
+ return 0;
+#elif defined(__linux__)
@@ copy.c: int copy_file_with_time(const char *dst, const char *src, int mode)
+ }
+ if (close(fdo) != 0)
+ return -1;
-+ if (adjust_shared_perm(the_repository, dst))
++ if (adjust_shared_perm(repo, dst))
++ return -1;
++ return 0;
++#elif defined(GIT_WINDOWS_NATIVE)
++ /*
++ * ReFS supports copy-on-write block cloning via
++ * FSCTL_DUPLICATE_EXTENTS_TO_FILE, the same mechanism Git LFS uses.
++ * Cloned ranges must start and end on a cluster boundary (ReFS uses
++ * 64K or 4K clusters) and span less than 4GiB, so clone in <=1GiB
++ * chunks and round the final chunk up to a cluster; the destination is
++ * sized to the source's exact length and an over-long final range past
++ * the source's EOF is ignored.
++ */
++ const uint64_t GiB = (uint64_t)1 << 30;
++ const uint64_t clusters[] = { 64 * 1024, 4 * 1024 };
++ int fdi, fdo, ret = -1;
++ HANDLE hi, ho;
++ LARGE_INTEGER size;
++ uint64_t off;
++ size_t i;
++
++ mode = (mode & 0111) ? 0777 : 0666;
++ if ((fdi = open(src, O_RDONLY | O_BINARY)) < 0)
++ return -1;
++ if ((fdo = open(dst, O_RDWR | O_CREAT | O_EXCL | O_BINARY, mode)) < 0) {
++ int saved = errno;
++ close(fdi);
++ errno = saved;
++ return -1;
++ }
++ hi = (HANDLE)_get_osfhandle(fdi);
++ ho = (HANDLE)_get_osfhandle(fdo);
++ if (hi == INVALID_HANDLE_VALUE || ho == INVALID_HANDLE_VALUE ||
++ !GetFileSizeEx(hi, &size)) {
++ errno = EINVAL;
++ goto cleanup;
++ }
++
++ /* the destination must be the source's exact size before cloning */
++ if (!SetFilePointerEx(ho, size, NULL, FILE_BEGIN) || !SetEndOfFile(ho)) {
++ errno = EIO;
++ goto cleanup;
++ }
++
++ ret = 0;
++ /* clone whole 1GiB regions (already cluster aligned) */
++ for (off = 0; (uint64_t)size.QuadPart > off &&
++ (uint64_t)size.QuadPart - off > GiB; off += GiB) {
++ if (clone_extent_windows(ho, hi, off, GiB)) {
++ errno = ENOSYS;
++ ret = -1;
++ goto cleanup;
++ }
++ }
++ /* clone the tail, rounding its length up to a cluster (64K then 4K) */
++ if (!ret && (uint64_t)size.QuadPart > off) {
++ uint64_t rem = (uint64_t)size.QuadPart - off;
++
++ ret = -1;
++ for (i = 0; i < ARRAY_SIZE(clusters); i++) {
++ uint64_t count = (rem + clusters[i] - 1) &
++ ~(clusters[i] - 1);
++ if (!clone_extent_windows(ho, hi, off, count)) {
++ ret = 0;
++ break;
++ }
++ }
++ if (ret)
++ errno = ENOSYS; /* not a CoW volume */
++ }
++
++cleanup:
++ close(fdi);
++ if (close(fdo) && !ret) {
++ errno = EIO;
++ ret = -1;
++ }
++ if (ret) {
++ int saved = errno;
++ unlink(dst);
++ errno = saved;
++ return -1;
++ }
++ if (adjust_shared_perm(repo, dst))
+ return -1;
+ return 0;
+#else
-+ /* No reflink support on this platform (e.g. Windows). */
++ /* No reflink primitive on this platform. */
++ (void)repo;
+ (void)dst;
+ (void)src;
+ (void)mode;
@@ copy.c: int copy_file_with_time(const char *dst, const char *src, int mode)
+}
## copy.h ##
-@@ copy.h: int copy_fd(int ifd, int ofd);
- int copy_file(const char *dst, const char *src, int mode);
- int copy_file_with_time(const char *dst, const char *src, int mode);
+@@ copy.h: int copy_file(struct repository *repo,
+ int copy_file_with_time(struct repository *repo,
+ const char *dst, const char *src, int mode);
+/*
+ * Create "dst" as a copy-on-write (reflink) clone of the regular file
@@ copy.h: int copy_fd(int ifd, int ofd);
+ * ENOSYS (or the underlying error). Callers are expected to fall back to
+ * copy_file() in that case. Returns 0 on success.
+ */
-+int reflink_file(const char *dst, const char *src, int mode);
++int reflink_file(struct repository *repo,
++ const char *dst, const char *src, int mode);
+
#endif /* COPY_H */
@@ t/t2400-worktree-add.sh: test_expect_success 'relative worktree sets extension c
+ echo artifact >expect &&
+ test_cmp expect wt/untracked.bin &&
+ git -C wt status --porcelain >status &&
-+ grep "?? untracked.bin" status
++ test_grep "?? untracked.bin" status
+ )
+'
+
2: 97f0fdad9b < -: ---------- worktree: allow sharing a checked-out branch across worktrees
-: ---------- > 2: 6e2acdc5e2 worktree: reap ephemeral branches created by "worktree add"
--
gitgitgadget
next prev parent reply other threads:[~2026-10-20 16:04 UTC|newest]
Thread overview: 8+ messages / expand[flat|nested] mbox.gz Atom feed top
2026-06-05 18:49 [PATCH 0/2] worktree: copy-on-write creation and shared-branch worktrees Jason Newton via GitGitGadget
2026-06-05 18:49 ` [PATCH 1/2] worktree: add --reflink for copy-on-write worktree creation Jason Newton via GitGitGadget
2026-06-05 18:49 ` [PATCH 2/2] worktree: allow sharing a checked-out branch across worktrees Jason Newton via GitGitGadget
2026-06-05 19:59 ` [PATCH 0/2] worktree: copy-on-write creation and shared-branch worktrees brian m. carlson
2026-06-08 14:36 ` Junio C Hamano
2026-10-20 16:04 ` Jason Newton via GitGitGadget [this message]
2026-10-20 16:04 ` [PATCH v2 1/2] worktree: add --reflink for copy-on-write worktree creation Jason Newton via GitGitGadget
2026-10-20 16:04 ` [PATCH v2 2/2] worktree: reap ephemeral branches created by "worktree add" Jason Newton via GitGitGadget
Reply instructions:
You may reply publicly to this message via plain-text email
using any one of the following methods:
* Save the following mbox file, import it into your mail client,
and reply-to-all from there: mbox
Avoid top-posting and favor interleaved quoting:
https://en.wikipedia.org/wiki/Posting_style#Interleaved_style
* Reply using the --to, --cc, and --in-reply-to
switches of git-send-email(1):
git send-email \
--in-reply-to=pull.2317.v2.git.git.1792512249.gitgitgadget@gmail.com \
--to=gitgitgadget@gmail.com \
--cc=git@vger.kernel.org \
--cc=nevion@gmail.com \
--cc=sandals@crustytoothpaste.net \
/path/to/YOUR_REPLY
https://kernel.org/pub/software/scm/git/docs/git-send-email.html
* If your mail client supports setting the In-Reply-To header
via mailto: links, try the mailto: link
Be sure your reply has a Subject: header at the top and a blank line
before the message body.
This is a public inbox, see mirroring instructions
for how to clone and mirror all data and code used for this inbox