Skip to main content

Module subvolume

Module subvolume 

Source
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 (openat2 with RESOLVE_BENEATH, RESOLVE_NO_SYMLINKS, RESOLVE_NO_XDEV and RESOLVE_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: libc has FICLONE and the filesystem magic numbers but no BTRFS_IOC_* constants, and the wrapper never starts the btrfs program (the repository’s rule: embed instead of spawning). The request numbers and the 4096-byte argument structures are taken from the kernel header include/uapi/linux/btrfs.h (the host copy is /usr/include/linux/btrfs.h): BTRFS_IOCTL_MAGIC is 0x94; BTRFS_IOC_SUBVOL_CREATE is _IOW(0x94, 14, struct btrfs_ioctl_vol_args); BTRFS_IOC_SNAP_DESTROY is _IOW(0x94, 15, struct btrfs_ioctl_vol_args); BTRFS_IOC_SNAP_CREATE_V2 is _IOW(0x94, 23, struct btrfs_ioctl_vol_args_v2). Both structures are 4096 bytes: vol_args is an s64 fd and 4088 name bytes; vol_args_v2 is an s64 fd, u64 transid, u64 flags, a 32-byte union and 4040 name bytes. strace of btrfs subvolume snapshot and btrfs subvolume delete shows 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 against libc::FICLONE. Permissions (kernel fs/btrfs/ioctl.c): creating a subvolume is unrestricted; a snapshot needs ownership of the source; destroying one needs the user_subvol_rm_allowed mount option for an unprivileged user.
fs_clone_exchange
FICLONE of one file into another and the atomic RENAME_EXCHANGE of two paths. What: Two more Linux primitives: clone a whole file’s data blocks into another file (FICLONE), and swap two paths atomically (renameat2 with RENAME_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: FICLONE answers EXDEV across filesystems, EOPNOTSUPP or EINVAL where the filesystem has no reflinks; the exchange answers EINVAL or EXDEV where the filesystem cannot swap. The ladder of doc/planning/cli-git-btrfs-worktree.md uses 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 statfs call 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 whether user_subvol_rm_allowed is active. Why: The option decides whether BTRFS_IOC_SNAP_DESTROY works 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.rs parses 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 is proc_pid_mountinfo(5): id parent major:minor root mount-point options [optional...] - fstype source super-options, with space, tab, newline and backslash escaped as \ooo octal 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/file is resolved again for every call, so a directory d replaced 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 of git 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-word git cli-git spellings (service status). Running it migrates after the preflight; --dry-run previews; --rollback and --remove-old are 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 .git in 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 status in 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 (UNTR untracked cache, FSMN fsmonitor 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 with index.skipHash has 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 of subvolume_index.rs. A file in .git/worktrees/<name>/gitdir holds the path of the worktree’s .git file, so the worktree is its parent directory (the same reading the migration script used). An index is replaced through index.lock as 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 done is skipped, a step marked started is 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_json is 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.lock file 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 creating index.lock exclusively, writing it and renaming it over index. 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-new as 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.md is 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 .git and .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_all stops 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, and remove_dir_all would 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_all crosses 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 with rmdir, 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 --delete and rsync --archive --update did 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 --archive and rsync --archive --xattrs did 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.