Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
b4e9194
Marshal libgit2 P/Invoke strings as UTF-8, not ANSI
tyrielv Sep 21, 2026
096f16a
Update default Microsoft Git version to v2.55.0.vfs.0.10
dscho Sep 23, 2026
92020a4
Merge pull request #2111 from microsoft/automation/gitrelease-v2.55.0…
dscho Sep 23, 2026
f4728ba
Merge pull request #2110 from tyrielv/tyrielv/libgit2-utf8-marshalling
tyrielv Sep 23, 2026
b3c837f
docs(agents): clarify functional test dev workflow
tyrielv Sep 23, 2026
fb3ea43
docs(agents): clarify payload prerequisites
tyrielv Sep 24, 2026
269288e
Merge pull request #2114 from tyrielv/tyrielv/agents-guidance
tyrielv Sep 24, 2026
51592aa
Retry native C++ project builds on transient MSBuild failure
tyrielv Sep 24, 2026
27d8d9a
Only retry native builds on the actual C3859 PCH error
tyrielv Sep 24, 2026
cb20708
Merge pull request #2117 from tyrielv/tyrielv/retry-native-pch-build
tyrielv Sep 24, 2026
b35b9b6
Add regression tests for reset --mixed skip-worktree on hydrated files
tyrielv Jun 10, 2026
ab95cf5
Merge pull request #2018 from tyrielv/tyrielv/fix-reset-mixed-skipwor…
tyrielv Sep 28, 2026
6dd6f63
Add IsSendingAnonymousRequests to VFS.Heartbeat
tyrielv Sep 30, 2026
a6c33d2
Show credential prompts above other windows for background mounts
tyrielv Oct 1, 2026
d274d51
Bump actions/upload-artifact from 7 to 7
dependabot[bot] Oct 6, 2026
78e3e57
Merge pull request #2128 from microsoft/dependabot/github_actions/act…
tyrielv Oct 6, 2026
a65ec22
Merge pull request #2125 from tyrielv/tyrielv/isanonymous-heartbeat
tyrielv Oct 7, 2026
58edd7c
Make credential-fill console-visibility telemetry authoritative and l…
tyrielv Oct 7, 2026
56d0b72
Merge pull request #2127 from tyrielv/tyrielv/credential-nowindow
tyrielv Oct 7, 2026
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
2 changes: 1 addition & 1 deletion .github/workflows/build.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ permissions:
checks: read

env:
GIT_VERSION: ${{ github.event.inputs.git_version || 'v2.55.0.vfs.0.8' }}
GIT_VERSION: ${{ github.event.inputs.git_version || 'v2.55.0.vfs.0.10' }}

jobs:
validate:
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/functional-tests.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -209,7 +209,7 @@ jobs:

- name: Upload failure diagnostics (mount dumps + logs)
if: always() && steps.skip.outputs.result != 'true'
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
continue-on-error: true
with:
name: ${{ env.ARTIFACT_PREFIX }}FailureDiagnostics_${{ env.FT_MATRIX_NAME }}
Expand Down
96 changes: 82 additions & 14 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,35 +64,64 @@ Skips `dotnet publish`, AOT, native C++ projects, payload assembly, installer.
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`.
`GVFS.Payload` only assembles the payload directory. It does not build or
publish the projects that it copies from.

> **Prerequisite: the native C++ projects must already be built.** They are
> `.vcxproj` (see [Native C++ projects](#native-c-projects-need-msbuild-not-dotnet-build)
> **Prerequisite: the native C++ projects and all managed payload projects must
> already have built outputs.** The native projects 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.
> projects via VS MSBuild and publish the managed payload projects below
> (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\GVFS.csproj `
-c Debug /p:PublishAot=false
dotnet publish src\GVFS\GVFS.Hooks\GVFS.Hooks.csproj `
-c Debug /p:PublishAot=false
dotnet publish src\GVFS\GVFS.Mount\GVFS.Mount.csproj `
-c Debug /p:PublishAot=false
dotnet publish src\GVFS\GVFS.Service\GVFS.Service.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>
src\scripts\RunFunctionalTests-Dev.ps1 -Configuration Debug -Arch x64 `
--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.

> **Publish each changed project explicitly.** `GVFS.Payload.csproj` has no
> `ProjectReference` items. Its `CreatePayload` target runs `layout.bat`, which
> copies existing project output. A project that you do not publish can
> contribute stale output from an earlier build. Publish all four managed
> payload projects at least once in a new enlistment, and publish a project
> again when you change it.

`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.
service as a console process. Each invocation gets a unique service name and
service-data directory. Some fixtures still use shared machine paths or drive
mappings, so do not assume that separate test processes can run concurrently.

The script sets dev-mode variables. `Settings.Default.Initialize` then resolves
`gvfs.exe` and `GVFS.Service.exe` from `out\GVFS.Payload\...`, not
`C:\Program Files\VFS for Git\`. This path does not require a GVFS
installation.

> **Pass `-Configuration` and `-Arch` by name.** The script's first two
> positional parameters are `Configuration` and `Arch`. A bare
> `RunFunctionalTests-Dev.ps1 Debug --test=...` binds `--test=...` to `-Arch`
> and fails its `ValidateSet`.

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

Expand Down Expand Up @@ -135,12 +164,14 @@ participate in the C# inner-loop paths above.

```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
& "out\GVFS.UnitTests\bin\...\GVFS.UnitTests.exe" --test "GVFS.UnitTests.Common.WorktreeInfoTests"
src\scripts\RunFunctionalTests-Dev.ps1 -Configuration Debug -Arch x64 `
--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"
& "out\GVFS.UnitTests\bin\...\GVFS.UnitTests.exe" --where "class =~ Worktree"
src\scripts\RunFunctionalTests-Dev.ps1 -Configuration Debug -Arch x64 `
--where "cat == Smoke"
```

For unit tests, `--where` is merely annoying (the whole suite runs in
Expand Down Expand Up @@ -199,6 +230,43 @@ Default new gates to `false` and gate the **runtime entry point** into a
feature, not its build, so the code still compiles and ships (and keeps
getting exercised) while its behavior stays off by default.

## libgit2 P/Invoke string marshalling (UTF-8, not ANSI)

The libgit2 bindings live in `GVFS.Common/Git/LibGit2Repo.cs` under the
`Native` class. **libgit2 treats every string it receives and returns as
UTF-8** — paths, revspecs, config keys and values, and error messages.

The .NET default for a bare `[DllImport]` string parameter is
`CharSet.Ansi`, which encodes through the Windows ANSI code page and
**silently corrupts non-ASCII input** (a repo path under a non-English user
name, a non-ASCII branch name, etc.) — an unmappable character becomes `?`,
so `git_repository_open` and friends resolve the wrong path or fail. There is
no `[module: DefaultCharSet]` override in `GVFS.Common`, so the ANSI default
applies unless each declaration opts out.

When you add a libgit2 P/Invoke:

- Annotate **every** `string` parameter, `out string` parameter, string return
value, and string struct field with
`[MarshalAs(UnmanagedType.LPUTF8Str)]`.
- Do **not** use `CharSet.Unicode` — that marshals UTF-16, which libgit2 does
not accept (it is the same bug in the other direction).
- For a function that returns a **borrowed** pointer owned by libgit2 (e.g.
`git_config_get_string`), marshal it as `IntPtr` and copy with
`Marshal.PtrToStringUTF8(...)`; do not marshal it as `out string`, or the
interop marshaller frees libgit2's heap memory with the wrong allocator.

Because the mismatch only manifests through the native call, cover new
string-carrying paths with a real-libgit2 test that uses a non-ASCII input
(see `GVFS.FunctionalTests/Tests/LibGit2NonAsciiPathTests.cs`), not a
mock-based unit test.

## Public repository hygiene

Do not include Microsoft-internal work item IDs, ADO URLs, incident IDs, or
internal service names in repository content or pull requests. Describe the
public problem and fix without requiring internal access.

## Coding standards

See [CONTRIBUTING.md](CONTRIBUTING.md) for StyleCop rules, error-handling
Expand Down
14 changes: 14 additions & 0 deletions GVFS/GVFS.Common/GVFSPlatform.cs
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,20 @@ public static void Register(GVFSPlatform platform)
/// </exception>
public abstract void PrepareProcessToRunInBackground();

/// <summary>
/// Returns true if the current process owns a visible console window that a child process
/// (e.g. git launching the credential manager) can use as the owner for an interactive prompt.
/// </summary>
/// <remarks>
/// This inspects the current process's console window and its root owner, not arbitrary GUI
/// windows. Background processes such as GVFS.Mount run with a hidden console, so this returns
/// false for them; foreground processes launched from a visible console return true. Credential
/// helpers launched by a process with no visible console window should be started detached from
/// the console, so the credential manager parents its prompt to a topmost stub window instead
/// of the hidden console.
/// </remarks>
public abstract bool CurrentProcessHasVisibleConsoleWindow();

public abstract bool IsProcessActive(int processId);

/// <summary>
Expand Down
74 changes: 65 additions & 9 deletions GVFS/GVFS.Common/Git/GitProcess.cs
Original file line number Diff line number Diff line change
Expand Up @@ -273,18 +273,27 @@ public virtual bool TryGetCertificatePassword(

using (ITracer activity = tracer.StartActivity("TryGetCertificatePassword", EventLevel.Informational))
{
// Record whether the current process has a visible console window, using the same value
// GetGitProcess uses to decide whether to detach git from a hidden console. Capturing it
// once here keeps the logged value authoritative and diagnosable on both the success and
// failure paths when a credential prompt fails to appear.
bool hasVisibleConsoleWindow = CurrentProcessHasVisibleConsoleWindowSafe();

// See GetFromConfig for why pre-command hook is disabled
// for bootstrap-time git operations.
Result gitCredentialOutput = this.InvokeGitAgainstDotGitFolder(
"credential fill",
stdin => stdin.Write("protocol=cert\npath=" + certificatePath + "\nusername=\n\n"),
parseStdOutLine: null,
usePreCommandHook: false);
usePreCommandHook: false,
mayRequireAuth: true,
hasVisibleConsoleWindow: hasVisibleConsoleWindow);

if (gitCredentialOutput.ExitCodeIsFailure)
{
EventMetadata errorData = new EventMetadata();
errorData.Add("CertificatePath", certificatePath);
errorData.Add("hasVisibleConsoleWindow", hasVisibleConsoleWindow);
tracer.RelatedWarning(
errorData,
"Git could not get credentials: " + gitCredentialOutput.Errors,
Expand All @@ -301,7 +310,8 @@ public virtual bool TryGetCertificatePassword(
EventMetadata metadata = new EventMetadata
{
{ "Success", success },
{ "CertificatePath", certificatePath }
{ "CertificatePath", certificatePath },
{ "hasVisibleConsoleWindow", hasVisibleConsoleWindow }
};

if (!success)
Expand All @@ -328,18 +338,27 @@ public virtual bool TryGetCredential(

using (ITracer activity = tracer.StartActivity(nameof(this.TryGetCredential), EventLevel.Informational))
{
// Record whether the current process has a visible console window, using the same value
// GetGitProcess uses to decide whether to detach git from a hidden console. Capturing it
// once here keeps the logged value authoritative and diagnosable on both the success and
// failure paths (including the timeout that a hidden, unanswered prompt produces).
bool hasVisibleConsoleWindow = CurrentProcessHasVisibleConsoleWindowSafe();

// See GetFromConfig for why pre-command hook is disabled
// for bootstrap-time git operations.
Result gitCredentialOutput = this.InvokeGitAgainstDotGitFolder(
GenerateCredentialVerbCommand("fill"),
stdin => stdin.Write($"url={repoUrl}\n\n"),
parseStdOutLine: null,
usePreCommandHook: false,
timeoutMs: timeoutMs);
timeoutMs: timeoutMs,
mayRequireAuth: true,
hasVisibleConsoleWindow: hasVisibleConsoleWindow);

if (gitCredentialOutput.ExitCodeIsFailure)
{
EventMetadata errorData = new EventMetadata();
errorData.Add("hasVisibleConsoleWindow", hasVisibleConsoleWindow);

if (gitCredentialOutput.Errors.StartsWith("Operation timed out"))
{
Expand All @@ -361,6 +380,14 @@ public virtual bool TryGetCredential(
return false;
}

// Record whether the current process had a visible console window. GetGitProcess uses
// this to decide whether to detach git from a hidden console so the credential prompt is
// not left behind other windows; logging it keeps that decision diagnosable from telemetry
// when a prompt fails to appear.
EventMetadata consoleMetadata = new EventMetadata();
consoleMetadata.Add("hasVisibleConsoleWindow", hasVisibleConsoleWindow);
activity.RelatedInfo(consoleMetadata, "Credential fill console window visibility");

username = ParseValue(gitCredentialOutput.Output, "username=");
password = ParseValue(gitCredentialOutput.Output, "password=");

Expand Down Expand Up @@ -902,7 +929,7 @@ public Result MultiPackIndexRepack(string gitObjectDirectory, string batchSize)
return this.InvokeGitAgainstDotGitFolder($"-c pack.threads=1 -c repack.packKeptObjects=true multi-pack-index repack --object-dir=\"{gitObjectDirectory}\" --batch-size={batchSize} --no-progress");
}

public Process GetGitProcess(string command, string workingDirectory, string dotGitDirectory, bool useReadObjectHook, string gitObjectsDirectory, bool usePreCommandHook)
public Process GetGitProcess(string command, string workingDirectory, string dotGitDirectory, bool useReadObjectHook, string gitObjectsDirectory, bool usePreCommandHook, bool mayRequireAuth = false, bool? hasVisibleConsoleWindow = null)
{
ProcessStartInfo processInfo = new ProcessStartInfo(this.gitBinPath);
processInfo.WorkingDirectory = workingDirectory;
Expand All @@ -919,7 +946,16 @@ public Process GetGitProcess(string command, string workingDirectory, string dot
// instead), CreateNoWindow must be set to true for that case — otherwise
// the non-redirected stream inherits the parent's console handle, which
// may be absent when running as a service, causing lost output.
processInfo.CreateNoWindow = false;
//
// When a command may require authentication, git can launch the credential
// manager. Detaching from a hidden console (CreateNoWindow=true) makes that
// prompt appear on top instead of behind other windows. See
// GVFSPlatform.CurrentProcessHasVisibleConsoleWindow for the full rationale.
// A caller that already probed the console visibility passes the result in
// hasVisibleConsoleWindow so the logged and acted-on value stay identical;
// otherwise probe here. The && short-circuits the probe when auth cannot
// be required.
processInfo.CreateNoWindow = mayRequireAuth && !(hasVisibleConsoleWindow ?? CurrentProcessHasVisibleConsoleWindowSafe());

processInfo.StandardOutputEncoding = UTF8NoBOM;
processInfo.StandardErrorEncoding = UTF8NoBOM;
Expand Down Expand Up @@ -986,7 +1022,9 @@ protected virtual Result InvokeGitImpl(
Action<string> parseStdOutLine,
int timeoutMs,
string gitObjectsDirectory = null,
bool usePreCommandHook = true)
bool usePreCommandHook = true,
bool mayRequireAuth = false,
bool? hasVisibleConsoleWindow = null)
{
if (failedToSetEncoding && writeStdIn != null)
{
Expand All @@ -998,7 +1036,7 @@ protected virtual Result InvokeGitImpl(
// From https://msdn.microsoft.com/en-us/library/system.diagnostics.process.standardoutput.aspx
// To avoid deadlocks, use asynchronous read operations on at least one of the streams.
// Do not perform a synchronous read to the end of both redirected streams.
using (this.executingProcess = this.GetGitProcess(command, workingDirectory, dotGitDirectory, useReadObjectHook, gitObjectsDirectory: gitObjectsDirectory, usePreCommandHook: usePreCommandHook))
using (this.executingProcess = this.GetGitProcess(command, workingDirectory, dotGitDirectory, useReadObjectHook, gitObjectsDirectory: gitObjectsDirectory, usePreCommandHook: usePreCommandHook, mayRequireAuth: mayRequireAuth, hasVisibleConsoleWindow: hasVisibleConsoleWindow))
{
// Bound how much stdout/stderr we buffer so a pathologically noisy git command
// cannot grow these buffers without limit until GVFS.Mount hits an
Expand Down Expand Up @@ -1086,6 +1124,20 @@ private static string GenerateCredentialVerbCommand(string verb)
return $"-c {GitConfigSetting.CredentialUseHttpPath}=true credential {verb}";
}

private static bool CurrentProcessHasVisibleConsoleWindowSafe()
{
try
{
return GVFSPlatform.Instance.CurrentProcessHasVisibleConsoleWindow();
}
catch (Exception ex) when (ex is DllNotFoundException || ex is EntryPointNotFoundException)
{
// If the native console probe cannot run, assume a visible console so callers keep
// the previous behavior (CreateNoWindow=false) rather than aborting the command.
return true;
}
}

private static string ParseValue(string contents, string prefix)
{
int startIndex = contents.IndexOf(prefix) + prefix.Length;
Expand Down Expand Up @@ -1166,7 +1218,9 @@ private Result InvokeGitAgainstDotGitFolder(
Action<string> parseStdOutLine,
bool usePreCommandHook = true,
string gitObjectsDirectory = null,
int timeoutMs = -1)
int timeoutMs = -1,
bool mayRequireAuth = false,
bool? hasVisibleConsoleWindow = null)
{
// This git command should not need/use the working directory of the repo.
// Run git.exe in Environment.SystemDirectory to ensure the git.exe process
Expand All @@ -1180,7 +1234,9 @@ private Result InvokeGitAgainstDotGitFolder(
parseStdOutLine: parseStdOutLine,
timeoutMs: timeoutMs,
gitObjectsDirectory: gitObjectsDirectory,
usePreCommandHook: usePreCommandHook);
usePreCommandHook: usePreCommandHook,
mayRequireAuth: mayRequireAuth,
hasVisibleConsoleWindow: hasVisibleConsoleWindow);
}

public class Result
Expand Down
Loading
Loading