Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
174 changes: 174 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
# Agent instructions for VFS for Git

This file is for AI coding assistants (GitHub Copilot, Claude Code, Cursor, OpenAI
Codex, Gemini CLI, Aider, etc.). It captures project-specific knowledge that isn't
obvious from a fresh `git clone` and which routinely trips up agents. See
[CONTRIBUTING.md](CONTRIBUTING.md) for coding standards (StyleCop rules, error
handling, exception logging) — do not duplicate those here.

## Repository layout

The build scripts (`scripts\Build.bat` and friends) put build outputs **one
level up** from the git working tree. The Readme documents the recommended
clone-into-`src\` convention, which matches the layout that `gvfs clone`
creates for end users:

```powershell
# Recommended (matches Readme.md and CI):
git clone https://github.com/microsoft/VFSForGit C:\Repos\VFSForGit\src
```

```
C:\Repos\VFSForGit\
├── src\ ← git working tree (.git, GVFS.sln, all source)
├── out\ ← build output (gitignored — it's outside the working tree)
└── packages\ ← NuGet cache
```

Cloning without the `src\` suffix also works — outputs just land one level
above wherever you cloned. The rest of this document and the project's own
scripts (CI, Readme) use the `src\` form, so commands assume it. **The
parent directory must be writable** (cloning to `C:\` itself won't work
because the build can't create `C:\out`).

All commands below run from the **enlistment root** (the parent of `src\`):

```powershell
cd C:\Repos\VFSForGit # NOT C:\Repos\VFSForGit\src
```

This keeps `src\...` and `out\...` paths symmetric and matches what
`Build.bat` does internally.

## Build paths

`scripts\Build.bat` does a full installer build with NativeAOT publish plus
Inno Setup. That's ~5 minutes minimum, and AOT has no incremental support
— ilc relinks every executable on every `dotnet publish`. **Do not use
`Build.bat` for dev-loop iteration.** Pick the right path for what you're
doing.

### Path A — Unit-test inner loop (~10–15 s incremental)

For C# changes verified by unit tests only.

```powershell
dotnet build src\GVFS\GVFS.UnitTests\GVFS.UnitTests.csproj -c Debug
& "out\GVFS.UnitTests\bin\Debug\net10.0-windows10.0.17763.0\win-x64\GVFS.UnitTests.exe" --test Fully.Qualified.Class.Or.Method
```

Skips `dotnet publish`, AOT, native C++ projects, payload assembly, installer.

### Path B — Functional-test inner loop (~30–60 s incremental)

For changes that need the GVFS payload (`gvfs.exe`, hooks, service) but not
an installer. `PublishAot=false` skips ilc (~3–4 min saved);
`SkipCreateInstaller=true` skips Inno Setup (~95 s saved).
`GVFS.Payload` cascades to its dependencies (GVFS, GVFS.Mount, GVFS.Hooks,
GVFS.Service) via `ProjectReference`.

> **Prerequisite: the native C++ projects must already be built.** They are
> `.vcxproj` (see [Native C++ projects](#native-c-projects-need-msbuild-not-dotnet-build)
> below) and `dotnet publish` will not build them for you. If you have not
> already done a `Build.bat` once in this enlistment, build the native
> projects via VS MSBuild first (or run `Build.bat` once to populate `out\`,
> then iterate with the commands below). After that they are incremental and
> only rebuild when their own sources change.

```powershell
dotnet publish src\GVFS\GVFS.FunctionalTests\GVFS.FunctionalTests.csproj `
-c Debug /p:PublishAot=false
dotnet publish src\GVFS\GVFS.Payload\GVFS.Payload.csproj `
-c Debug /p:PublishAot=false /p:SkipCreateInstaller=true

src\scripts\RunFunctionalTests-Dev.ps1 Debug --test=GVFS.FunctionalTests.Tests.<Namespace>.<Class>.<Method>
```

`layout.bat` (invoked by GVFS.Payload) `xcopy`s from each project's `publish\`
or native-output directory — the C# projects do not require AOT, so
`PublishAot=false` produces a fully functional test payload. The native
hook binaries are copied straight from the vcxproj output.

`RunFunctionalTests-Dev.ps1` runs functional tests against the build output
without requiring admin or a system-wide install. It launches the test
service as a console process. Each invocation gets a unique service name
and data dir, so concurrent runs from different worktrees don't collide.

### Path C — Installer build (~5 min — only when you need an installer)

For producing an installable package (testing install/upgrade flows, or
shipping a build). This is the only correct use of `Build.bat`.

```powershell
# Build.bat takes (configuration, version, verbosity). The 0. prefix on the
# version tells GVFS to treat this as a development build and skip the
# server-side version check.
$v = & { $n = [DateTime]::Now; "0.2.$($n.ToString('yy'))$($n.DayOfYear.ToString('D3')).$([int]($n.TimeOfDay.TotalSeconds / 2))" }
src\scripts\Build.bat Debug $v minimal
```

Installer output: `out\GVFS.Installers\bin\Debug\win-x64\SetupGVFS.<version>.exe`.

## Native C++ projects (need MSBuild, not `dotnet build`)

These five projects are `.vcxproj` and require Visual Studio MSBuild with
the C++ workload. `Build.bat` invokes MSBuild for them automatically; if
you need to rebuild them outside `Build.bat`, use `msbuild`, not
`dotnet build`.

- `GVFS\GitHooksLoader\GitHooksLoader.vcxproj`
- `GVFS\GVFS.NativeTests\GVFS.NativeTests.vcxproj`
- `GVFS\GVFS.PostIndexChangedHook\GVFS.PostIndexChangedHook.vcxproj`
- `GVFS\GVFS.ReadObjectHook\GVFS.ReadObjectHook.vcxproj`
- `GVFS\GVFS.VirtualFileSystemHook\GVFS.VirtualFileSystemHook.vcxproj`

These only need to be (re)built when their own sources change; they don't
participate in the C# inner-loop paths above.

## Running tests

### NUnit filter syntax — `--test`, NEVER `--where`

> **⚠️ This codebase uses NUnitLite, which supports ONLY `--test` for name
> filtering. The `--where` filter (from NUnit Console) is silently ignored
> and runs every test.**

```powershell
# ✅ Correct
& "out\GVFS.UnitTests\bin\...\GVFS.UnitTests.exe" --test "GVFS.UnitTests.Common.WorktreeInfoTests"
src\scripts\RunFunctionalTests-Dev.ps1 Debug --test=GVFS.FunctionalTests.Tests.GVFSVerbTests.UnknownVerb

# ❌ Wrong — silently runs the entire suite
& "out\GVFS.UnitTests\bin\...\GVFS.UnitTests.exe" --where "class =~ Worktree"
src\scripts\RunFunctionalTests-Dev.ps1 Debug --where "cat == Smoke"
```

For unit tests, `--where` is merely annoying (the whole suite runs in
~10 seconds). For functional tests, it's a disaster: each test provisions
its own fresh enlistment, so accidentally running the full suite eats
hours and masks whichever failure you were actually investigating.

### Fully qualified names required

`--test` matches against the fully qualified name (`Namespace.Class.Method`
or `Namespace.Class`). Short names like `--test=ReproCherryPickRestoreCorruption`
silently match nothing and the runner reports "0 tests selected" without
making the typo obvious.

## vcpkg caching

`Build.bat` checks for `out\vcpkg_installed\dynamic\x64-windows-dynamic\bin\
git2.dll` as a "vcpkg already installed" marker and skips the vcpkg step
if present. The vcpkg step is the slowest part of a from-scratch build
(several minutes of native compilation), so this caching matters.

Do not manually delete or re-run vcpkg unless you've changed an overlay
port. If you've copied `out\` from another enlistment as a build-time
shortcut, vcpkg results come along with it.

## Coding standards

See [CONTRIBUTING.md](CONTRIBUTING.md) for StyleCop rules, error-handling
patterns (`TryXxx` over exceptions, "fail fast" on data-loss risks),
tracing/logging conventions (Error level reserved for unrecoverable
failures), and the `mock:\\` / `mock://` URL convention for unit tests.
6 changes: 3 additions & 3 deletions GVFS/FastFetch/CheckoutPrefetcher.cs
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ public override void Prefetch(string branchOrCommit, bool isBranch)
commitToFetch = branchOrCommit;
}

using (new IndexLock(this.Enlistment.EnlistmentRoot, this.Tracer))
using (new IndexLock(this.Enlistment.PrimaryEnlistmentRoot, this.Tracer))
{
this.DownloadMissingCommit(commitToFetch, this.GitObjects);

Expand Down Expand Up @@ -124,7 +124,7 @@ public override void Prefetch(string branchOrCommit, bool isBranch)
if (!indexGen.HasFailures)
{
Index newIndex = new Index(
this.Enlistment.EnlistmentRoot,
this.Enlistment.PrimaryEnlistmentRoot,
this.Tracer,
indexGen.TemporaryIndexFilePath,
readOnly: false);
Expand Down Expand Up @@ -200,7 +200,7 @@ private Index GetSourceIndex()

if (File.Exists(indexPath))
{
Index output = new Index(this.Enlistment.EnlistmentRoot, this.Tracer, indexPath, readOnly: true);
Index output = new Index(this.Enlistment.PrimaryEnlistmentRoot, this.Tracer, indexPath, readOnly: true);
output.Parse();
return output;
}
Expand Down
2 changes: 1 addition & 1 deletion GVFS/FastFetch/FastFetchVerb.cs
Original file line number Diff line number Diff line change
Expand Up @@ -240,7 +240,7 @@ private int ExecuteWithExitCode()
CacheServerInfo cacheServer = new CacheServerInfo(this.GetRemoteUrl(enlistment), null);

tracer.WriteStartEvent(
enlistment.EnlistmentRoot,
enlistment.PrimaryEnlistmentRoot,
enlistment.RepoUrl,
cacheServer.Url,
new EventMetadata
Expand Down
2 changes: 1 addition & 1 deletion GVFS/FastFetch/GitEnlistment.cs
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ private GitEnlistment(string repoRoot, string gitBinPath)

public string FastFetchLogRoot
{
get { return Path.Combine(this.EnlistmentRoot, GVFSConstants.DotGit.Root, ".fastfetch"); }
get { return Path.Combine(this.PrimaryEnlistmentRoot, GVFSConstants.DotGit.Root, ".fastfetch"); }
}

public static GitEnlistment CreateFromCurrentDirectory(string gitBinPath)
Expand Down
4 changes: 2 additions & 2 deletions GVFS/GVFS.Common/Database/GVFSDatabase.cs
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,10 @@ public class GVFSDatabase : IGVFSConnectionPool, IDisposable
private IDbConnectionFactory connectionFactory;
private BlockingCollection<IDbConnection> connectionPool;

public GVFSDatabase(PhysicalFileSystem fileSystem, string enlistmentRoot, IDbConnectionFactory connectionFactory, int initialPooledConnections = InitialPooledConnections)
public GVFSDatabase(PhysicalFileSystem fileSystem, string dotGVFSRoot, IDbConnectionFactory connectionFactory, int initialPooledConnections = InitialPooledConnections)
{
this.connectionPool = new BlockingCollection<IDbConnection>();
this.databasePath = Path.Combine(enlistmentRoot, GVFSPlatform.Instance.Constants.DotGVFSRoot, GVFSConstants.DotGVFS.Databases.VFSForGit);
this.databasePath = Path.Combine(dotGVFSRoot, GVFSConstants.DotGVFS.Databases.VFSForGit);
this.connectionFactory = connectionFactory;

string folderPath = Path.GetDirectoryName(this.databasePath);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ public override bool TryUpgrade(ITracer tracer, string enlistmentRoot)
}

using (placeholderList)
using (GVFSDatabase database = new GVFSDatabase(fileSystem, enlistmentRoot, new SqliteDatabase()))
using (GVFSDatabase database = new GVFSDatabase(fileSystem, Path.Combine(enlistmentRoot, GVFSPlatform.Instance.Constants.DotGVFSRoot), new SqliteDatabase()))
{
PlaceholderTable placeholders = new PlaceholderTable(database);
List<IPlaceholderData> oldPlaceholderEntries = placeholderList.GetAllEntries();
Expand Down
4 changes: 2 additions & 2 deletions GVFS/GVFS.Common/Enlistment.cs
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ protected Enlistment(
throw new ArgumentException("Path to git.exe must be set");
}

this.EnlistmentRoot = enlistmentRoot;
this.PrimaryEnlistmentRoot = enlistmentRoot;
this.WorkingDirectoryRoot = workingDirectoryRoot;
this.WorkingDirectoryBackingRoot = workingDirectoryBackingRoot;
this.DotGitRoot = Path.Combine(this.WorkingDirectoryBackingRoot, GVFSConstants.DotGit.Root);
Expand Down Expand Up @@ -52,7 +52,7 @@ protected Enlistment(
this.Authentication = authentication ?? new GitAuthentication(gitProcess, this.RepoUrl);
}

public string EnlistmentRoot { get; }
public string PrimaryEnlistmentRoot { get; }

// Path to the root of the working (i.e. "src") directory.
// On platforms where the contents of the working directory are stored
Expand Down
4 changes: 2 additions & 2 deletions GVFS/GVFS.Common/FileSystem/HooksInstaller.cs
Original file line number Diff line number Diff line change
Expand Up @@ -229,7 +229,7 @@ private static bool TryUpdateHook(
metadata.Add(nameof(installedHookPath), installedHookPath);
metadata.Add("Exception", e.ToString());
context.Tracer.RelatedError(metadata, "Failed to compare " + hookName + " version");
errorMessage = "Error comparing " + hookName + " versions. " + ConsoleHelper.GetGVFSLogMessage(context.Enlistment.EnlistmentRoot);
errorMessage = "Error comparing " + hookName + " versions. " + ConsoleHelper.GetGVFSLogMessage(context.Enlistment.WorkingDirectoryRoot);
return false;
}
}
Expand All @@ -248,7 +248,7 @@ private static bool TryUpdateHook(
metadata.Add(nameof(installedHookPath), installedHookPath);
metadata.Add("Exception", e.ToString());
context.Tracer.RelatedError(metadata, "Failed to copy " + hookName + " to enlistment");
errorMessage = "Error copying " + hookName + " to enlistment. " + ConsoleHelper.GetGVFSLogMessage(context.Enlistment.EnlistmentRoot);
errorMessage = "Error copying " + hookName + " to enlistment. " + ConsoleHelper.GetGVFSLogMessage(context.Enlistment.WorkingDirectoryRoot);
return false;
}
}
Expand Down
6 changes: 3 additions & 3 deletions GVFS/GVFS.Common/GVFSEnlistment.cs
Original file line number Diff line number Diff line change
Expand Up @@ -30,11 +30,11 @@ public GVFSEnlistment(string enlistmentRoot, string repoUrl, string gitBinPath,
flushFileBuffersForPacks: true,
authentication: authentication)
{
this.NamedPipeName = GVFSPlatform.Instance.GetNamedPipeName(this.EnlistmentRoot);
this.DotGVFSRoot = Path.Combine(this.EnlistmentRoot, GVFSPlatform.Instance.Constants.DotGVFSRoot);
this.NamedPipeName = GVFSPlatform.Instance.GetNamedPipeName(this.PrimaryEnlistmentRoot);
this.DotGVFSRoot = Path.Combine(this.PrimaryEnlistmentRoot, GVFSPlatform.Instance.Constants.DotGVFSRoot);
this.GitStatusCacheFolder = Path.Combine(this.DotGVFSRoot, GVFSConstants.DotGVFS.GitStatusCache.Name);
this.GitStatusCachePath = Path.Combine(this.DotGVFSRoot, GVFSConstants.DotGVFS.GitStatusCache.CachePath);
this.GVFSLogsRoot = Path.Combine(this.EnlistmentRoot, GVFSPlatform.Instance.Constants.DotGVFSRoot, GVFSConstants.DotGVFS.LogName);
this.GVFSLogsRoot = Path.Combine(this.DotGVFSRoot, GVFSConstants.DotGVFS.LogName);
this.LocalObjectsRoot = Path.Combine(this.WorkingDirectoryBackingRoot, GVFSConstants.DotGit.Objects.Root);
}

Expand Down
8 changes: 7 additions & 1 deletion GVFS/GVFS.Common/Git/GitRepo.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,19 @@ public class GitRepo : IDisposable
private PhysicalFileSystem fileSystem;
private LibGit2RepoInvoker libgit2RepoInvoker;
private Enlistment enlistment;
private string dotGVFSRoot;

public GitRepo(ITracer tracer, Enlistment enlistment, PhysicalFileSystem fileSystem, Func<LibGit2Repo> repoFactory = null)
{
this.tracer = tracer;
this.enlistment = enlistment;
this.fileSystem = fileSystem;

// Resolve the per-worktree .gvfs root if available; otherwise
// derive from EnlistmentRoot (primary enlistments).
this.dotGVFSRoot = (enlistment as GVFSEnlistment)?.DotGVFSRoot
?? Path.Combine(enlistment.PrimaryEnlistmentRoot, GVFSPlatform.Instance.Constants.DotGVFSRoot);

this.GVFSLock = new GVFSLock(tracer);

this.libgit2RepoInvoker = new LibGit2RepoInvoker(
Expand Down Expand Up @@ -240,7 +246,7 @@ private LooseBlobState GetLooseBlobStateAtPath(string blobPath, Action<Stream, l
{
if (corruptLooseObject)
{
string corruptBlobsFolderPath = Path.Combine(this.enlistment.EnlistmentRoot, GVFSPlatform.Instance.Constants.DotGVFSRoot, GVFSConstants.DotGVFS.CorruptObjectsName);
string corruptBlobsFolderPath = Path.Combine(this.dotGVFSRoot, GVFSConstants.DotGVFS.CorruptObjectsName);
string corruptBlobPath = Path.Combine(corruptBlobsFolderPath, Path.GetRandomFileName());

EventMetadata metadata = new EventMetadata();
Expand Down
5 changes: 2 additions & 3 deletions GVFS/GVFS.Common/Git/RequiredGitConfig.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ public static class RequiredGitConfig
/// Returns the dictionary of required git config settings for a GVFS enlistment.
/// These settings override any existing local configuration values.
/// </summary>
public static Dictionary<string, string> GetRequiredSettings(Enlistment enlistment)
public static Dictionary<string, string> GetRequiredSettings(GVFSEnlistment enlistment)
{
string expectedHooksPath = Path.Combine(enlistment.DotGitRoot, GVFSConstants.DotGit.Hooks.RootName);
expectedHooksPath = Paths.ConvertPathToGitFormat(expectedHooksPath);
Expand All @@ -31,8 +31,7 @@ public static Dictionary<string, string> GetRequiredSettings(Enlistment enlistme
if (!GVFSEnlistment.IsUnattended(tracer: null) && GVFSPlatform.Instance.IsGitStatusCacheSupported())
{
gitStatusCachePath = Path.Combine(
enlistment.EnlistmentRoot,
GVFSPlatform.Instance.Constants.DotGVFSRoot,
enlistment.DotGVFSRoot,
GVFSConstants.DotGVFS.GitStatusCache.CachePath);

gitStatusCachePath = Paths.ConvertPathToGitFormat(gitStatusCachePath);
Expand Down
2 changes: 1 addition & 1 deletion GVFS/GVFS.Common/HealthCalculator/EnlistmentPathData.cs
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ public void LoadPlaceholdersFromDatabase(GVFSEnlistment enlistment)
List<IPlaceholderData> filePlaceholders = new List<IPlaceholderData>();
List<IPlaceholderData> folderPlaceholders = new List<IPlaceholderData>();

using (GVFSDatabase database = new GVFSDatabase(new PhysicalFileSystem(), enlistment.EnlistmentRoot, new SqliteDatabase()))
using (GVFSDatabase database = new GVFSDatabase(new PhysicalFileSystem(), enlistment.DotGVFSRoot, new SqliteDatabase()))
{
PlaceholderTable placeholderTable = new PlaceholderTable(database);
placeholderTable.GetAllEntries(out filePlaceholders, out folderPlaceholders);
Expand Down
2 changes: 1 addition & 1 deletion GVFS/GVFS.Common/LocalCacheResolver.cs
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ public static bool TryGetDefaultLocalCacheRoot(GVFSEnlistment enlistment, out st
return true;
}

return GVFSPlatform.Instance.TryGetDefaultLocalCacheRoot(enlistment.EnlistmentRoot, out localCacheRoot, out localCacheRootError);
return GVFSPlatform.Instance.TryGetDefaultLocalCacheRoot(enlistment.PrimaryEnlistmentRoot, out localCacheRoot, out localCacheRootError);
}

public bool TryGetLocalCacheKeyFromLocalConfigOrRemoteCacheServers(
Expand Down
2 changes: 1 addition & 1 deletion GVFS/GVFS.Common/Maintenance/GitMaintenanceStep.cs
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ public static bool EnlistmentRootReady(GVFSContext context)
// a "Device is not ready" error.
try
{
return context.FileSystem.DirectoryExists(context.Enlistment.EnlistmentRoot)
return context.FileSystem.DirectoryExists(context.Enlistment.WorkingDirectoryRoot)
&& context.FileSystem.DirectoryExists(context.Enlistment.GitObjectsRoot);
}
catch (IOException)
Expand Down
Loading
Loading