Skip to content

worktree: transparent copy-on-write population via reflinks#2183

Draft
walter-zeromatter wants to merge 7 commits into
gitgitgadget:masterfrom
ZeromatterOSS:worktree-reflink-series
Draft

worktree: transparent copy-on-write population via reflinks#2183
walter-zeromatter wants to merge 7 commits into
gitgitgadget:masterfrom
ZeromatterOSS:worktree-reflink-series

Conversation

@walter-zeromatter

Copy link
Copy Markdown

Populating a new worktree currently writes every file by inflating blobs from the object database, so N worktrees cost N full checkouts of disk and I/O. On filesystems with copy-on-write support (btrfs, XFS, bcachefs), the new worktree's files can instead be lightweight clones of the primary checkout's files.

This series teaches git worktree add to do that transparently: no new command-line option, a single worktree.useReflink config (default true) as an escape hatch, and silent per-file fallback to the regular checkout path everywhere else — unsupported filesystems, other platforms, or any file that fails the eligibility gate. Output and results are byte-identical to an unpatched git in all cases.

This follows up on the discussion around the earlier --reflink proposal (gitgitgadget#2317):

  • Per Junio's suggestion there, the copy-on-write happens inside the checkout write path (entry.c:write_entry()) as a transparent optimization rather than an opt-in mode: if a clean file with the same content exists in the primary worktree, clone it; otherwise write from the object database.
  • brian m. carlson's dirty-donor concern is addressed structurally: eligibility is decided per file against the donor's index — same path, same oid, same mode, not skip-worktree, stat-clean (racily-clean entries conservatively treated as dirty, assume-unchanged and fsmonitor validity ignored so a real stat comparison happens), no content conversion in effect, and on-disk size equal to blob size. There is no blind tree copy.

Because a freshly cloned or committed donor is racily clean in its entirety, git worktree add refreshes the primary worktree's index (update-index -q --refresh, non-fatal) before spawning the checkout, in an environment scrubbed of repo-local variables so git -C <linked-worktree> worktree add refreshes the right index.

Input-style CRLF paths (text eol=lf, text=auto with core.autocrlf=input) are also eligible: their checkout-direction conversion is the identity, and since CRLF cleaning only ever removes bytes, the size==blob gate makes a byte-divergent donor impossible. This mattered in practice: git.git's own .gitattributes would otherwise exclude ~68% of its files.

Reflinked files flow into the normal refresh_cache handling, so their stat information lands in the new index and they are never re-hashed — unlike external tools (git-cow-worktree, coworktree) that reflink behind git's back and pay a full re-hash on the next status.

Benchmark (btrfs, kernel 7.0.9, git.git itself, 4779 files): worktree add allocates ~12 KiB of new data instead of ~48 MiB, with 4773/4780 files reflinked, at slightly better wall time than a regular checkout (no zlib inflation for cloned files).

Platform support is Linux FICLONE only in this series; the wrapper is a stub returning ENOSYS elsewhere. macOS clonefile() and Windows ReFS could be follow-ups behind the same reflink_file() interface. Files handled by parallel checkout (checkout.workers > 1) take the normal write path, correct but unaccelerated.

Tests: new t2408 with a REFLINK lazy prerequisite (and !REFLINK cases for the fallback path), covering the happy path via trace2 counters, dirty/racy/assume-unchanged/skip-worktree/mode-mismatch donors, conversion attributes, symlinks, gitlinks, executable bits, the kill switch, --no-checkout, and invocation from a linked worktree.

🤖 Generated with Claude Code

walter-zeromatter and others added 7 commits July 16, 2026 11:36
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 <noreply@anthropic.com>
Signed-off-by: Walter Gray <walter@0m.dev>
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 <noreply@anthropic.com>
Signed-off-by: Walter Gray <walter@0m.dev>
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 <noreply@anthropic.com>
Signed-off-by: Walter Gray <walter@0m.dev>
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 <walter@0m.dev>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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 <noreply@anthropic.com>
Signed-off-by: Walter Gray <walter@0m.dev>
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 <linked-worktree> worktree
add ...", the parent process has GIT_DIR exported pointing at the
linked worktree's gitdir (.git/worktrees/<id>). 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 <noreply@anthropic.com>
Signed-off-by: Walter Gray <walter@0m.dev>
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 <noreply@anthropic.com>
Signed-off-by: Walter Gray <walter@0m.dev>
@gitgitgadget

gitgitgadget Bot commented Jul 16, 2026

Copy link
Copy Markdown

Welcome to GitGitGadget

Hi @walter-zeromatter, and welcome to GitGitGadget, the GitHub App to send patch series to the Git mailing list from GitHub Pull Requests.

Please make sure that either:

  • Your Pull Request has a good description, if it consists of multiple commits, as it will be used as cover letter.
  • Your Pull Request description is empty, if it consists of a single commit, as the commit message should be descriptive enough by itself.

You can CC potential reviewers by adding a footer to the PR description with the following syntax:

CC: Revi Ewer <revi.ewer@example.com>, Ill Takalook <ill.takalook@example.net>

NOTE: DO NOT copy/paste your CC list from a previous GGG PR's description,
because it will result in a malformed CC list on the mailing list. See
example.

Also, it is a good idea to review the commit messages one last time, as the Git project expects them in a quite specific form:

  • the lines should not exceed 76 columns,
  • the first line should be like a header and typically start with a prefix like "tests:" or "revisions:" to state which subsystem the change is about, and
  • the commit messages' body should be describing the "why?" of the change.
  • Finally, the commit messages should end in a Signed-off-by: line matching the commits' author.

It is in general a good idea to await the automated test ("Checks") in this Pull Request before contributing the patches, e.g. to avoid trivial issues such as unportable code.

Contributing the patches

Before you can contribute the patches, your GitHub username needs to be added to the list of permitted users. Any already-permitted user can do that, by adding a comment to your PR of the form /allow. A good way to find other contributors is to locate recent pull requests where someone has been /allowed:

Both the person who commented /allow and the PR author are able to /allow you.

An alternative is the channel #git-devel on the Libera Chat IRC network:

<newcontributor> I've just created my first PR, could someone please /allow me? https://github.com/gitgitgadget/git/pull/12345
<veteran> newcontributor: it is done
<newcontributor> thanks!

Once on the list of permitted usernames, you can contribute the patches to the Git mailing list by adding a PR comment /submit.

If you want to see what email(s) would be sent for a /submit request, add a PR comment /preview to have the email(s) sent to you. You must have a public GitHub email address for this. Note that any reviewers CC'd via the list in the PR description will not actually be sent emails.

After you submit, GitGitGadget will respond with another comment that contains the link to the cover letter mail in the Git mailing list archive. Please make sure to monitor the discussion in that thread and to address comments and suggestions (while the comments and suggestions will be mirrored into the PR by GitGitGadget, you will still want to reply via mail).

If you do not want to subscribe to the Git mailing list just to be able to respond to a mail, you can download the mbox from the Git mailing list archive (click the (raw) link), then import it into your mail program. If you use GMail, you can do this via:

curl -g --user "<EMailAddress>:<Password>" \
    --url "imaps://imap.gmail.com/INBOX" -T /path/to/raw.txt

To iterate on your change, i.e. send a revised patch or patch series, you will first want to (force-)push to the same branch. You probably also want to modify your Pull Request description (or title). It is a good idea to summarize the revision by adding something like this to the cover letter (read: by editing the first comment on the PR, i.e. the PR description):

Changes since v1:
- Fixed a typo in the commit message (found by ...)
- Added a code comment to ... as suggested by ...
...

To send a new iteration, just add another PR comment with the contents: /submit.

Need help?

New contributors who want advice are encouraged to join git-mentoring@googlegroups.com, where volunteers who regularly contribute to Git are willing to answer newbie questions, give advice, or otherwise provide mentoring to interested contributors. You must join in order to post or view messages, but anyone can join.

You may also be able to find help in real time in the developer IRC channel, #git-devel on Libera Chat. Remember that IRC does not support offline messaging, so if you send someone a private message and log out, they cannot respond to you. The scrollback of #git-devel is archived, though.

@walter-zeromatter

Copy link
Copy Markdown
Author

Note: I've got this in draft while I iterate on it & check CI state. I'll mark it as ready for review when I've actually gone through the code by hand. I'm using AI for a lot of the generation but will be reviewing every line before marking ready for anyone else to look at.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant