Skip to content

git worktree support for VFSForGit#1911

Open
tyrielv wants to merge 15 commits intomicrosoft:masterfrom
tyrielv:tyrielv/gvfs-worktree-2
Open

git worktree support for VFSForGit#1911
tyrielv wants to merge 15 commits intomicrosoft:masterfrom
tyrielv:tyrielv/gvfs-worktree-2

Conversation

@tyrielv
Copy link
Contributor

@tyrielv tyrielv commented Mar 12, 2026

git worktree support for VFSForGit

Motivation

VS Code Copilot Chat background agents use git worktree to create isolated working directories for code editing. On VFSForGit enlistments, git worktree is blocked by BLOCK_ON_VFS_ENABLED because the VFS layer had no support for multiple working trees. This PR enables worktree support by running an independent GVFS mount (ProjFS instance) per worktree.

Companion PR

microsoft/git: tyrielv/gvfs-worktree (1 commit, 4 files, +82 lines)

  • Adds GVFS_SUPPORTS_WORKTREES (1<<8) to the core.gvfs bitmask
  • Conditionally allows git worktree when the bit is set
  • Forces --no-checkout when VFS is active (GVFS handles checkout after mount)
  • Adds skip-clean-check marker for pre-unmount cleanliness verification
  • Tests in t0402-block-command-on-gvfs.sh

Design

Each worktree gets its own:

  • ProjFS virtualization instance rooted at the worktree directory
  • Named pipe (GVFS_<ROOT>_WT_<NAME>) for IPC with hooks
  • Index projection reading the worktree's own index file
  • Metadata at .git/worktrees/<name>/.gvfs/
  • Service registration by worktree path (independent of primary)

Worktrees share:

  • Object cache — same shared cache, no re-download
  • Hook executables — shared via core.hookspath (absolute path)
  • Git config — shared .git/config via git's commondir mechanism

How it works

  1. User runs git worktree add <path> → git creates the worktree structure with --no-checkout (forced by the git-side change)
  2. Post-command hook detects worktree add → runs git checkout -f to create the index → runs gvfs mount <path>
  3. GVFS detects the worktree (.git file → gitdir:.git/worktrees/<name>/) → creates worktree-specific GVFSEnlistment → starts ProjFS → files appear
  4. All 5 hooks (read-object, post-index-changed, virtual-filesystem, pre-command, post-command) connect to the worktree's mount via the _WT_<NAME> pipe suffix
  5. git worktree remove → pre-command hook unmounts ProjFS → git deletes the directory

Commits (review in order)

# Commit What to review
1 common: add worktree detection and enlistment support TryGetWorktreeInfo() detection logic, CreateForWorktree() path mappings
2 hooks: make pipe name resolution worktree-aware Native C++ .git file detection in GetGVFSPipeName(), pipe suffix derivation
3 mount: teach gvfs mount/unmount to handle worktrees MountVerb/UnmountVerb worktree detection, service registration by worktree path, InProcessMount metadata bootstrap, index path fixes
4 hooks: auto-mount/unmount worktrees via git hooks Pre/post-command orchestration for add/remove/move/prune, skip-clean-check flow, remount-on-failure recovery, WorktreeCommandParser arg extraction
5 tests: add worktree unit and functional tests 3 unit test classes (451 lines), functional tests (163 lines)
The remaining commits were added to address PR feedback
6-8 Block nesting worktrees, Block worktree remove without --force if not mounted
9 hooks: fix index copy race and vfs-hook script cleanup Atomic index copy via temp+rename, try/finally for .vfs-empty-hook cleanup
10 mount: wrap RepoMetadata init/shutdown in try/finally Guarantee singleton cleanup during worktree metadata bootstrap
11 hooks: fix MAX_PATH overflow and simplify unmount wait Dynamic buffers in native worktree detection, replace pipe polling with simple sleep
12 common: store enlistment root in worktree gitdir, remove path assumptions Explicit marker file replaces GetDirectoryName chains; use WorkingDirectoryRootName constant
13 misc: restore unmount log path, API compat overload, narrow catch Fix null gvfsEnlistmentRoot, add WaitUntilMounted backward-compat overload, specific exception types in TryGetWorktreeInfo
14 tests: fix worktree cleanup to use gvfs unmount Replace broken Process.StartInfo matching with gvfs unmount for stuck mount cleanup
15 tests: concurrent worktree creation, commit, and removal Parallel add/remove of two worktrees, cross-worktree commit visibility, shared object cache verification

Testing done

  • git worktree add — auto-mounts, files visible via ProjFS
  • git worktree list — shows primary + worktrees
  • git worktree move — unmounts old, mounts new
  • git worktree remove — unmounts, cleans up
  • git worktree prune — cleans stale entries
  • ✅ Concurrent worktrees — two simultaneous worktrees, independent mounts
  • ✅ Edit/commit in worktree — commit visible from primary
  • gvfs service --unmount-all / --mount-all — worktrees survive the cycle
  • ✅ Large repo (500k+ trees) — add/move/remove works
  • ✅ All 616 unit tests pass
  • ✅ Git tests (t0402) — 18/18 pass

Known limitations

  • If worktree remove partially succeeds (ProjFS unmounted but directory deletion fails due to locked file), the worktree is left in a degraded state. The post-command hook attempts remount only if both the directory and .git file still exist.
  • The added VFSForGit functional tests will not pass in the PR validation build until the corresponding git.exe changes have been released.

@tyrielv tyrielv force-pushed the tyrielv/gvfs-worktree-2 branch 2 times, most recently from 2ff5914 to ab088d8 Compare March 12, 2026 15:51
Tyrie Vella and others added 3 commits March 12, 2026 08:56
Add TryGetWorktreeInfo() to detect git worktrees by checking for a .git
file (not directory) and reading its gitdir pointer. WorktreeInfo carries
the worktree name, paths, and derived pipe suffix.

Add GVFSEnlistment.CreateForWorktree() factory that constructs an
enlistment with worktree-specific paths: WorkingDirectoryRoot points to
the worktree, DotGitRoot uses the shared .git directory, and
NamedPipeName includes a worktree-specific suffix.

Add WorktreeCommandParser to extract subcommands and positional args
from git worktree hook arguments.

Add GVFS_SUPPORTS_WORKTREES to GitCoreGVFSFlags enum.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Update GetGVFSPipeName() in common.windows.cpp to detect when running
inside a git worktree. If the current directory contains a .git file
(not directory), read the gitdir pointer, extract the worktree name,
and append a _WT_<NAME> suffix to the pipe name.

This single change makes all native hooks (read-object,
post-index-changed, virtual-filesystem) connect to the correct
worktree-specific GVFS mount process.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
MountVerb: detect worktree paths via TryGetWorktreeInfo(), create
worktree-specific GVFSEnlistment, check worktree-specific pipe for
already-mounted state, register worktrees by their own path (not the
primary enlistment root).

UnmountVerb: resolve worktree pipe name for unmount, unregister by
worktree path so the primary enlistment registration is not affected.

InProcessMount: bootstrap worktree metadata (.gvfs/ inside worktree
gitdir), set absolute paths for core.hookspath and
core.virtualfilesystem, skip hook installation for worktree mounts
(hooks are shared via hookspath), set GVFS_SUPPORTS_WORKTREES bit.

GitIndexProjection/FileSystemCallbacks: use worktree-specific index
path instead of assuming primary .git/index.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@tyrielv tyrielv force-pushed the tyrielv/gvfs-worktree-2 branch 3 times, most recently from 153f071 to 07dbad0 Compare March 12, 2026 16:12
@tyrielv tyrielv marked this pull request as ready for review March 12, 2026 16:20
Tyrie Vella and others added 2 commits March 12, 2026 11:09
In the managed pre/post-command hooks, intercept git worktree
subcommands to transparently manage GVFS mounts:

  add:    Post-command runs 'git checkout -f' to create the index,
          then 'gvfs mount' to start ProjFS projection.
  remove: Pre-command checks for uncommitted changes while ProjFS
          is alive, writes skip-clean-check marker, unmounts.
          Post-command remounts if removal failed (dir + .git exist).
  move:   Pre-command unmounts old path, post-command mounts new.
  prune:  Post-command cleans stale worktree metadata.

Add WorktreeCommandParser reference to GVFS.Hooks.csproj.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Unit tests:
  WorktreeInfoTests — TryGetWorktreeInfo detection, pipe suffix
  WorktreeEnlistmentTests — CreateForWorktree path mappings
  WorktreeCommandParserTests — subcommand and arg extraction

Functional tests:
  WorktreeTests — end-to-end add/list/remove with live GVFS mount
  GitBlockCommandsTests — update existing test for conditional block

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@tyrielv tyrielv force-pushed the tyrielv/gvfs-worktree-2 branch from 07dbad0 to e4314b3 Compare March 12, 2026 18:09
Tyrie Vella and others added 2 commits March 12, 2026 13:41
ProjFS cannot handle nested virtualization roots. Add a pre-command
check that blocks 'git worktree add' when the target path is inside
the primary enlistment's working directory.

Add IsPathInsideDirectory() utility to GVFSEnlistment.Shared.cs with
unit tests for path matching (case-insensitive, sibling paths allowed).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Before removing a worktree, probe the named pipe to verify the GVFS
mount is running. If not mounted:
- Without --force: error with guidance to mount or use --force
- With --force: skip unmount and let git proceed

Refactor UnmountWorktree to accept a pre-resolved WorktreeInfo to
avoid redundant TryGetWorktreeInfo calls.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Normalize paths in IsPathInsideDirectory using Path.GetFullPath to
prevent traversal attacks with segments like '/../'. Add
GetKnownWorktreePaths to enumerate existing worktrees from the
.git/worktrees directory, and block creating a worktree inside any
existing worktree — not just the primary VFS working directory.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copy link

@KeithIsSleeping KeithIsSleeping left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Risk Analysis Review

Reviewed from an OS developer perspective, focusing on GVFS + worktree integration risks, ProjFS concerns, test coverage, and deployment safety.

Assumption: GVFS is only used in the OS repo, so OS-specific layout assumptions (e.g., src/ subdirectory) are acceptable.

Summary: 3 critical issues, 4 high-risk issues, several medium concerns and test gaps. See inline comments for details.

Tyrie Vella and others added 7 commits March 17, 2026 09:14
Copy the primary index to a temp file first, then rename atomically
into the worktree's index path. A direct File.Copy on a live index
risks a torn read on large indexes. Clean up the temp file on failure.

Wrap the .vfs-empty-hook script creation and deletion in try/finally
so the file is always cleaned up even if git checkout crashes.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Guarantee RepoMetadata.Shutdown() is called even if an unexpected
exception occurs between TryInitialize and Shutdown. Without this,
the process-global singleton could be left pointing at the wrong
metadata directory.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Replace MAX_PATH fixed buffers in GetWorktreePipeSuffix() with
std::wstring/std::string to handle long worktree paths safely.
Use dynamic MultiByteToWideChar sizing instead of fixed buffer.

Replace the 10-iteration pipe polling loop after gvfs unmount with
a simple sleep. The unmount command already blocks until the mount
process exits; the sleep allows remaining ProjFS handles to close.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…ions

Write the primary enlistment root to a marker file in the worktree gitdir
during creation (gvfs-enlistment-root). WorktreeInfo.GetEnlistmentRoot()
reads this marker, falling back to the GetDirectoryName chain for
worktrees created before this change.

Replace all GetDirectoryName(GetDirectoryName(SharedGitDir)) chains
in MountVerb, UnmountVerb, GVFSMountProcess, and GVFSEnlistment with
the new GetEnlistmentRoot() method.

Replace hardcoded "src" with GVFSConstants.WorkingDirectoryRootName
in the nested worktree path check.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Restore enlistmentRoot parameter in UnmountVerb.AcquireLock so the
"Run gvfs log" message appears on lock acquisition failure.

Add backward-compatible WaitUntilMounted(tracer, enlistmentRoot,
unattended, out error) overload for out-of-tree callers.

Narrow bare catch in TryGetWorktreeInfo to IOException and
UnauthorizedAccessException to avoid swallowing unexpected errors.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Process.StartInfo.Arguments is empty for externally-launched processes,
so the old code that matched GVFS.Mount by arguments would never find
or kill stuck mounts. Replace with gvfs unmount which uses the named
pipe to cleanly shut down the mount process.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Replace the single-worktree functional test with a concurrent test
that creates two worktrees in parallel, verifies both have projected
files and clean status, commits in both, verifies cross-visibility
of commits (shared objects), and removes both in parallel.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copy link

@KeithIsSleeping KeithIsSleeping left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approved -- see below

Copy link

@KeithIsSleeping KeithIsSleeping left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All review items have been addressed in commits 6-15:

Review Item Status Commit
CRITICAL: Index copy race ✅ Fixed — atomic temp+rename e1ec486
CRITICAL: RepoMetadata singleton ✅ Fixed — try/finally 0b19c7a
CRITICAL: core.gvfs unconditional ✅ Acknowledged — bit-check is safe noted in thread
HIGH: MAX_PATH native hooks ✅ Fixed — dynamic buffers 137571b
HIGH: Hardcoded "src" path ✅ Fixed — uses WorkingDirectoryRootName 8351b71
HIGH: Fragile enlistment root derivation ✅ Fixed — explicit marker file 8351b71
HIGH: Unmount timeout ✅ Fixed — simplified wait 137571b
MEDIUM: Service registration orphaning Open — see thread
MEDIUM: No worktree limit ✅ Acknowledged noted in thread
MEDIUM: null gvfsEnlistmentRoot ✅ Fixed b58d060
MEDIUM: Shell script cleanup ✅ Fixed — try/finally e1ec486
MEDIUM: WaitUntilMounted API break ✅ Fixed — compat overload b58d060
MINOR: Bare catch ✅ Fixed — specific exceptions b58d060
MINOR: --orphan flag ✅ N/A — my comment was incorrect, --orphan is a boolean flag
TEST GAP: Broken cleanup ✅ Fixed — uses gvfs unmount 6b4e6b2
TEST GAP: Concurrent tests ✅ Added 321b5a4

LGTM — all critical and high items resolved. Only the service registration orphaning item remains open as a future consideration.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants