Expand description
Btrfs subvolume worktrees and the filesystem primitives under them. What: Btrfs subvolume worktrees and the filesystem primitives under them. Why: Platform-specific code is easier to audit in one folder.
In TS you’d write (pseudocode):
// subvolume/index.ts: one `export * from './file'` per module below.Modules§
- fs_
beneath - Directory-relative file operations that never follow a link: the held-handle primitives of the clone rung and of the
snapshot’s stub removal.
What:
Directory-relative file operations that never follow a symbolic link or cross a mount: open a directory, a file or a
link below a held directory handle one plain name at a time (
openat2withRESOLVE_BENEATH,RESOLVE_NO_SYMLINKS,RESOLVE_NO_XDEVandRESOLVE_NO_MAGICLINKS), create entries there, set times on them, rename them, publish an open file under a new name without replacing anything, and remove a lock file whose identity is the one that was recorded. Why: A path string is resolved again by every call that takes it, so a directory swapped for a link between two calls redirects the second one. A name that was checked may also name another object when it is unlinked, and nothing in the kernel makes the two one step, so no tree is deleted through this module: what the ladder made is kept and reported. The clone rung and the snapshot’s stub removal work on trees other processes can rename into; holding the handle of each directory and naming one component at a time keeps every call on the object that was looked at. - fs_
btrfs_ ioctl - The btrfs ioctls that create, snapshot and destroy a subvolume, called in process.
What:
The three btrfs ioctls the wrapper needs, called in process: create an empty subvolume, snapshot a subvolume,
and destroy a subvolume.
Why:
libchasFICLONEand the filesystem magic numbers but noBTRFS_IOC_*constants, and the wrapper never starts thebtrfsprogram (the repository’s rule: embed instead of spawning). The request numbers and the 4096-byte argument structures are taken from the kernel headerinclude/uapi/linux/btrfs.h(the host copy is/usr/include/linux/btrfs.h):BTRFS_IOCTL_MAGICis 0x94;BTRFS_IOC_SUBVOL_CREATEis_IOW(0x94, 14, struct btrfs_ioctl_vol_args);BTRFS_IOC_SNAP_DESTROYis_IOW(0x94, 15, struct btrfs_ioctl_vol_args);BTRFS_IOC_SNAP_CREATE_V2is_IOW(0x94, 23, struct btrfs_ioctl_vol_args_v2). Both structures are 4096 bytes:vol_argsis ans64fd and 4088 name bytes;vol_args_v2is ans64fd,u64transid,u64flags, a 32-byte union and 4040 name bytes.straceofbtrfs subvolume snapshotandbtrfs subvolume deleteshows the same shapes (doc/planning/cli-git-btrfs-worktree.md, “Facts”). Each call is proven by one real call in a test on a btrfs fixture, and the encoding of the request numbers is proven againstlibc::FICLONE. Permissions (kernelfs/btrfs/ioctl.c): creating a subvolume is unrestricted; a snapshot needs ownership of the source; destroying one needs theuser_subvol_rm_allowedmount option for an unprivileged user. - fs_
clone_ exchange FICLONEof one file into another and the atomicRENAME_EXCHANGEof two paths. What: Two more Linux primitives: clone a whole file’s data blocks into another file (FICLONE), and swap two paths atomically (renameat2withRENAME_EXCHANGE). Why: A reflink copy shares the data blocks of a file instead of copying them (btrfs, XFS with reflink, and files inside one overlayfs whose layers share such a filesystem), and an exchange swaps a directory with its replacement in one step, so no process ever sees the path missing. Both fail with a typed error the caller falls through on:FICLONEanswersEXDEVacross filesystems,EOPNOTSUPPorEINVALwhere the filesystem has no reflinks; the exchange answersEINVALorEXDEVwhere the filesystem cannot swap. The ladder ofdoc/planning/cli-git-btrfs-worktree.mduses the clone as its second rung.- fs_kind
- Which filesystem holds a path (btrfs, overlayfs, other) and whether a directory is a btrfs subvolume root.
What:
Which kind of filesystem holds a path (btrfs, overlayfs, other), whether a directory is the root of a btrfs
subvolume, and how many bytes a user can still write there.
Why:
Every rung of the cheap-worktree ladder starts from these three facts, which the kernel answers in one
statfscall and one inode number, so nothing is spawned: a snapshot needs btrfs and a subvolume root (inode 256), a reflink works on btrfs and inside an overlay, and everything else keeps Git’s own checkout.doc/planning/cli-git-btrfs-worktree.md(section “Degradation ladder”) has the measurements behind the ladder. - fs_
mount_ options - The mount of a path read from
/proc/self/mountinfo, with its options (user_subvol_rm_allowed). What: The mount of a path read from/proc/self/mountinfo: its mount point, filesystem type, source and both option lists, so a caller can ask whetheruser_subvol_rm_allowedis active. Why: The option decides whetherBTRFS_IOC_SNAP_DESTROYworks for an unprivileged user (the kernel checks the mount’s super options), and the doctor must tell a person which command turns it on. Reading the table in the process spawns nothing.filesystem_identity_mount.rsparses the same table but keeps only the fields it needs and declines the whole table on a shape it does not know; this reader keeps the option fields and skips a line it cannot read, because a missed line can only hide a mount the path is not under. The line grammar isproc_pid_mountinfo(5):id parent major:minor root mount-point options [optional...] - fstype source super-options, with space, tab, newline and backslash escaped as\ooooctal in the path fields. - fs_
primitive_ error - The typed failure of one Linux filesystem primitive:
the call, the path and the error number.
What:
The typed failure of one Linux filesystem primitive (
statfs, a btrfs ioctl,FICLONE,renameat2): which operation, on which path, and the operating system’s error number. Why: The primitives of the cheap-worktree ladder and the subvolume migration all fall through on failure, never panic; the caller decides from the error number whether the next rung applies (the filesystem lacks the feature) or the work must stop (a real fault), and the message names the operation and path a person can act on. - subvolume_
anchor - An open directory that every operation inside it is addressed through, so a replaced path cannot redirect them.
What:
An open directory used as the fixed point of every operation inside it: the directory is opened one component at
a time without following symbolic links, and names inside it are then addressed through its descriptor.
Why:
The tree passes write into a checkout that other programs may be changing (the catch-up and the carry-back run
against a tree in use). A path such as
destination/d/fileis resolved again for every call, so a directorydreplaced by a symbolic link between two calls would send the write, the removal or the permission change outside the tree. Opening each directory without following links and addressing its children through/proc/self/fd/<descriptor>/<name>makes every call land in the directory that was checked, whatever happens to its path afterwards. Only the last component of a child path is still looked up by name, and the operations on it (exclusive create, rename, unlink,lstat) never follow a link there. - subvolume_
arguments - The grammar of
git cli-git migrate-to-subvolume. What: The grammar ofgit cli-git migrate-to-subvolume, the wrapper’s command that converts the main checkout into a btrfs subvolume, and the help text. Why: The command name says what it does and follows the other multi-wordgit cli-gitspellings (service status). Running it migrates after the preflight;--dry-runpreviews;--rollbackand--remove-oldare the two ways out; the rest only switch off a part of the migration. Modes that exclude each other, and switches that belong to one mode, are refused with the reason. - subvolume_
command git cli-git migrate-to-subvolume: root discovery, modes and exit statuses. What:git cli-git migrate-to-subvolume: find the repository’s main checkout from the working directory, run the requested mode (migrate, rollback, remove the old tree) and turn the result into an exit status and text. Why: The command runs for minutes (the real checkout took about sixteen minutes of passes), so progress lines go to standard error as they happen; refusals and failures end with the cause and every way forward. Exit status 0 means done, already done or previewed, 1 means a condition refused it before anything changed, and 2 means a step failed after the journal began, with the instruction to run the command again or to roll back. The main checkout is found by reading.gitin process (a directory, or a gitfile naming the common directory), never by asking Git.- subvolume_
entry_ count - The count of the entries under a checkout, from which the free space a migration needs follows.
What:
The count of the entries under a checkout: every file, directory and link below its root, counted once each.
Why:
The free space a migration needs is the metadata of its entries (
subvolume_preflight.rs), so the refusal for too little space starts from how many entries the copy will write. The walk reads directory listings only, no file contents, and follows no link, as the copy pass does not. - subvolume_
error - The failure type of the subvolume migration and its simulated-kill hook.
What:
The failure type of the subvolume migration and the test hook that simulates a kill.
Why:
Every step names what it did to which path and why it stopped, so the report can tell the person the cause, the
state the journal holds, and the way forward. The interrupt hook stops a run at a named point exactly as a kill
would (all state is on disk or in the journal; nothing else survives a kill), so the resume controls can stop at
every journal step without killing the test process. Release code passes
Interrupt::never(). - subvolume_
index - The stat rewrite of a Git index file’s bytes.
What:
The stat rewrite of a Git index: the bytes of an index file in, the bytes of the same index out, with the stat
fields of each entry replaced by what the file on disk reports now.
Why:
A reflink copy gives every file a new inode number and change time, so the first
git statusin the copied tree finds every entry stat-dirty and hashes every file (22.1 s measured against 0.17 s with the rewrite, and the clean filter of Git LFS runs on LFS files). The rewrite touches an entry only when the file’s size and modification time equal the entry’s, so a file that really changed stays dirty. Entry lengths never change, so the entries are edited in place. The accelerator extensions that record stat-derived state (UNTRuntracked cache,FSMNfsmonitor token) or offsets into the entries (EOIE,IEOT) are dropped, because Git rebuilds them; a split index (link) or a sparse index (sdir) is left untouched. The checksum is recomputed with the hash that verifies the old one (SHA-1, else SHA-256); an index written withindex.skipHashhas an all-zero checksum and keeps one. Versions 2, 3 and 4 are read (Documentation/gitformat-index.adoc). - subvolume_
index_ files - The linked worktrees of a main checkout and the index stat rewrite applied to files.
What:
The linked worktrees of a main checkout, found from
.git/worktrees/*/gitdir, and the stat rewrite applied to the index files of the checkout and of those worktrees on disk. Why: The subvolume migration copies the main checkout and the linked worktrees inside it, so each of their indexes needs the rewrite ofsubvolume_index.rs. A file in.git/worktrees/<name>/gitdirholds the path of the worktree’s.gitfile, so the worktree is its parent directory (the same reading the migration script used). An index is replaced throughindex.lockas Git replaces it, so a running Git command is never raced: the lock is taken before the index is read, and when it exists the index is left as copied and the report says so. The migration holds the locks itself while it completes the new checkout, and its rewrite then runs under them. - subvolume_
journal - The migration journal beside the checkout.
What:
The journal of a subvolume migration: a small JSON file beside the checkout that records which steps finished,
written atomically after each step.
Why:
A rerun after a kill at any instant resumes from it: a step marked
doneis skipped, a step markedstartedis repeated (every step is idempotent), and a step with no entry has not begun. The file lives in the checkout’s parent directory (.cli-git-migrate-<name>.journal.json), outside the tree that is copied and exchanged, so it survives the exchange. It is replaced by writing a sibling file, created exclusively under a name no other file has (so nothing planted or left at a fixed name is truncated or followed), and renaming it, so a kill never leaves half a journal. The plain files a killed run left under the writer’s own temporary names are removed by the next write that lands.serde_jsonis already a dependency of the crate. - subvolume_
lock - The index locks the migration holds, marked with a token that names the migration.
What:
The index locks the migration holds: one
index.lockfile for the main index and one for the index of each linked worktree, each carrying a token that names this migration. Why: Git writes an index by creatingindex.lockexclusively, writing it and renaming it overindex. A migration that swaps the checkout directory under such a writer, or copies a half-written lock into the new tree, damages the index. So the migration creates the lock files itself, in the old tree before its last copy and in the new tree before the exchange, and keeps them until the indexes are rewritten. The token makes a lock the migration’s own: a lock file that exists when the migration tries to create one is accepted only if it holds this migration’s token, so another Git command’s lock is never mistaken for it, never exempted from the refusals, and never removed. The linked worktrees’ administrative directories live inside.git, so their locks and indexes travel with the main checkout whether or not the worktree directory is inside it. - subvolume_
migrate - Converting a main checkout into a btrfs subvolume, resumably.
What:
Convert a main checkout into a btrfs subvolume at the same path, in process, resumably.
Why:
The steps are those of the proven script (
doc/handover/cli-git-subvolume-migration-script.md), without any program started: create<root>.subvol-newas a subvolume; create the linked worktrees inside it as nested subvolumes; copy the tree with cloned data; repeat the copy as a delta; hold.git/index.lock, run the last delta and swap the two directories in one atomic step; rename the old tree aside; carry over files newer in the old tree; rewrite the Git index stat fields; verify. Each step is recorded in the journal (subvolume_journal.rs), so a rerun after a kill at any point resumes: a finished step is skipped and an unfinished one is repeated, and all of them are idempotent. The old tree is never deleted here. - subvolume_
platform - The operating-system operations of the migration behind one trait, and the Linux implementation.
What:
The operating-system operations the subvolume migration needs, behind one trait, and the Linux implementation
that calls the primitives (
fs_kind,fs_btrfs_ioctl,fs_clone_exchange,fs_mount_options). Why: The migration’s journal, resume, rollback and refusal logic is the same on any filesystem; only five operations need btrfs. With the operations behind a trait, the controls run the whole logic on a plain directory tree with a stand-in platform where there is no btrfs (the container gate), and run it against the real platform on btrfs (the host task).doc/planning/cli-git-btrfs-worktree.mdis the design the primitives come from. - subvolume_
preflight - The paths of a migration and the refusals that stop it before anything is created.
What:
The paths of one migration and the refusals that stop it before anything is created: every refusal names its cause
and every remedy that applies.
Why:
Converting a checkout swaps its directory under every process and worktree, so the migration starts only when the
filesystem, the checkout and the state left by an earlier run allow it. The conditions are those of the proven
script (
doc/handover/cli-git-subvolume-migration-script.md, “Refusals”) with the rest of the owner’s rules: nothing is spawned, and a refusal is text a person can act on. A condition checks only metadata (the dry run reads.gitand.git/worktrees/*/gitdir, and lists every directory of the tree once to count its entries for the free-space check, without opening a file). - subvolume_
remove - Removal of a directory tree whose directories may lack owner write permission.
What:
Remove a directory tree, making each directory the user owns writable on the way down, and never going into a nested
subvolume or a mount point.
Why:
The migration preserves permission bits, so a checkout can hold a mode-555 directory with files in it. Unlinking a
file needs write permission on its directory, so
std::fs::remove_dir_allstops at the first such directory and both the deletion of the old tree and the emptying of a subvolume fail partway. Every directory is opened below its parent’s descriptor without following links and given the owner’s read, write and search bits through its descriptor before the tree is removed; a directory the user does not own keeps its mode and the removal reports the operating system’s refusal. A nested subvolume or a mount point inside the tree is not part of it (to the kernel it is a directory, andremove_dir_allwould delete what it holds), so the removal refuses before it deletes anything (subvolume_remove_walk.rs). - subvolume_
remove_ walk - What:
Empty a directory that is held open, one descriptor-relative step at a time, refusing every nested subvolume and mount
point before anything is deleted.
Why:
std::fs::remove_dir_allcrosses into a nested subvolume (it is a directory to the kernel) and into a bind mount, and deletes what is there; those hold data that is not part of the tree being removed. Every directory is opened below the descriptor of its parent without following links or crossing a mount (openat2), compared with the device of the top directory (a nested subvolume has its own), and then emptied through its own descriptor, so a path swapped for another directory or a link after the check cannot redirect any deletion. A first pass visits the whole tree and deletes nothing; the second pass does the same checks again at each directory, which catches a boundary that appeared in between. - subvolume_
rollback - Undoing a migration and deleting the old tree.
What:
Undo a migration (
--rollback) and delete the old tree after a finished one (--remove-old), plus the removal of a subvolume tree that both depend on. Why: Rollback carries back what was written to the new root since the exchange (files newer there win), swaps the trees again, rewrites the restored indexes’ stat fields (the carried-back index describes the new tree’s inodes), and deletes the new subvolume deepest nested subvolume first, because the kernel refuses to destroy a subvolume that holds another (ENOTEMPTY). Where the mount does not allow an unprivileged destroy, an emptied subvolume is removed withrmdir, which the kernel allows for an empty subvolume root. The old tree is an ordinary directory, so deleting it is a file walk, never a subvolume destroy. - subvolume_
run - The state a migration command carries between its steps. What: The state one migration command carries between its steps: the platform, the paths, the journal, the progress sink, the list of files this user may not read, and the helper that runs one tree pass and reports it. Why: The migrate, rollback and remove-old commands share it, so each pass is timed, counted and reported the same way and the unreadable-file list is written the same way after every pass (a kill between passes loses nothing).
- subvolume_
sha - SHA-1 and SHA-256, for the checksum of a rewritten Git index. What: SHA-1 and SHA-256 of a byte string, written out in full. Why: The last bytes of a Git index file are a checksum of everything before them (SHA-1, or SHA-256 in a repository of that object format), and Git refuses an index whose checksum is wrong. The index stat rewrite of the subvolume migration must write a new checksum. The crate has no hash function, a new dependency was not shown to be needed for two short functions, and the controls compare both functions with the published test vectors and, in the migration controls, with the checksum real Git accepts.
- subvolume_
sync - One idempotent pass that makes a directory tree match another, in process.
What:
Make one directory tree match another, in process: copy what is missing or changed (cloning file data where the
filesystem can), optionally delete what the source no longer has, or only add what is newer in the source.
Why:
This is the work
cp --archive --reflink=always,rsync --archive --deleteandrsync --archive --updatedid in the migration script, without starting those programs. One idempotent function serves all three passes, so a pass interrupted at any point is repeated safely: an entry already equal (same type, size, modification time and permission bits) is left alone. The walk uses a work stack, not recursion, so a deep tree cannot overflow the stack; a directory’s own attributes are applied after its children, because adding a child changes its modification time. Files this user may not read (for example root-owned mode 600 logs) are left where they are and listed; any other failure, and a directory this user cannot list, stops the run (the rule of the 2026-11-01 real run: copying around an unreadable directory with deletion on would erase its subtree from the copy). Every entry is written under a temporary name in its directory and renamed over its name when complete, so a kill never leaves a half-written file at a name a later pass would take for a newer one. The destination is reached through open directory descriptors (subvolume_anchor.rs), so a directory replaced by a link during the pass redirects nothing. An update-only pass never removes a destination entry that may hold newer work: a directory, or an entry of another type that is not older than the source, is kept and listed. - subvolume_
sync_ attrs - Copying owner, permissions, times and extended attributes onto a copy.
What:
Copying the attributes of one directory entry onto its copy: owner, permission bits, access and modification
times to the nanosecond, and extended attributes (user attributes and POSIX ACLs included); and recreating a
named pipe.
Why:
cp --archiveandrsync --archive --xattrsdid this in the migration script. The index stat rewrite trusts that sizes and modification times survive the copy, and Git compares the mode and owner of each entry, so the copy must carry them. Failures that an unprivileged user must expect are tolerated and counted, never silent: an owner other than the caller’s cannot be set (EPERM), and a security label (security.selinux) is set by the policy for a new file, not by the caller. A failure that no unprivileged user should meet (a permission change or a time change refused for lack of space, say) is an error, because the copy would be recorded as faithful when it is not. The attributes are applied through the descriptor of the copy, so a path swapped for a link while the pass runs cannot redirect them; only a symbolic link, which cannot be opened, is addressed by its path.