Skip to content
Open
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
5 changes: 5 additions & 0 deletions GVFS/GVFS.Common/GVFSConstants.cs
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,11 @@ public static class GitConfig

public const string ShowHydrationStatus = GVFSPrefix + "show-hydration-status";
public const bool ShowHydrationStatusDefault = false;

public const string DehydrateOnCheckoutPlaceholderPercent = GVFSPrefix + "auto-dehydrate-placeholder-percent";
public const string DehydrateOnCheckoutModifiedPercent = GVFSPrefix + "auto-dehydrate-modified-percent";
public const string DehydrateOnCheckoutFolderPercent = GVFSPrefix + "auto-dehydrate-folder-percent";
public const int DehydrateOnCheckoutDisabled = -1;
}

public static class LocalGVFSConfig
Expand Down
59 changes: 59 additions & 0 deletions GVFS/GVFS.Common/Git/GitIndexHelper.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
using System;
using System.IO;

namespace GVFS.Common.Git
{
/// <summary>
/// Lightweight utilities for reading git index metadata without full parsing.
/// </summary>
public static class GitIndexHelper
{
private const int IndexHeaderSize = 12;
private const int EntryCountOffset = 8;
private const int EntryCountSize = 4;

/// <summary>
/// Reads the entry count from the git index file header (bytes 8-11, big-endian).
/// This is extremely fast (~0.1ms) because it reads only 4 bytes, unlike
/// full index parsing which must allocate and parse every entry.
/// </summary>
/// <param name="indexPath">Full path to the git index file (e.g. .git/index).</param>
/// <returns>The number of entries, or -1 if the file cannot be read.</returns>
public static int ReadEntryCount(string indexPath)
{
using (FileStream indexFile = new FileStream(
indexPath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite))
{
return ReadEntryCount(indexFile);
}
}

/// <summary>
/// Reads the entry count from a git index stream (bytes 8-11, big-endian).
/// </summary>
/// <param name="indexStream">A readable stream positioned anywhere; will be seeked to offset 8.</param>
/// <returns>The number of entries, or -1 if the stream is too short.</returns>
public static int ReadEntryCount(Stream indexStream)
{
if (indexStream.Length < IndexHeaderSize)
{
return -1;
}

indexStream.Position = EntryCountOffset;
byte[] bytes = new byte[EntryCountSize];
int bytesRead = indexStream.Read(bytes, 0, EntryCountSize);
if (bytesRead < EntryCountSize)
{
return -1;
}

if (BitConverter.IsLittleEndian)
{
Array.Reverse(bytes);
}

return BitConverter.ToInt32(bytes, 0);
}
}
}
31 changes: 31 additions & 0 deletions GVFS/GVFS.Common/Git/LibGit2Repo.cs
Original file line number Diff line number Diff line change
Expand Up @@ -255,6 +255,34 @@ public virtual string GetConfigString(string name)
}
}

public virtual int? GetConfigInt(string name)
{
IntPtr configHandle;
if (Native.Config.GetConfig(out configHandle, this.RepoHandle) != Native.ResultCode.Success)
{
throw new LibGit2Exception($"Failed to get config handle: {Native.GetLastError()}");
}
try
{
int value;
Native.ResultCode resultCode = Native.Config.GetInt32(out value, configHandle, name);
if (resultCode == Native.ResultCode.NotFound)
{
return null;
}
else if (resultCode != Native.ResultCode.Success)
{
return null;
}

return value;
}
finally
{
Native.Config.Free(configHandle);
}
}

public void ForEachMultiVarConfig(string key, MultiVarConfigCallback callback)
{
if (Native.Config.GetConfig(out IntPtr configHandle, this.RepoHandle) != Native.ResultCode.Success)
Expand Down Expand Up @@ -570,6 +598,9 @@ private static string MarshalUtf8String(IntPtr ptr)
[DllImport(Git2NativeLibName, EntryPoint = "git_config_get_bool")]
public static extern ResultCode GetBool(out bool value, IntPtr configHandle, string name);

[DllImport(Git2NativeLibName, EntryPoint = "git_config_get_int32")]
public static extern ResultCode GetInt32(out int value, IntPtr configHandle, string name);

[DllImport(Git2NativeLibName, EntryPoint = "git_config_free")]
public static extern void Free(IntPtr configHandle);
}
Expand Down
11 changes: 11 additions & 0 deletions GVFS/GVFS.Common/Git/LibGit2RepoInvoker.cs
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,17 @@ public bool GetConfigBoolOrDefault(string key, bool defaultValue)
return defaultValue;
}

public int GetConfigIntOrDefault(string key, int defaultValue)
{
int? value = defaultValue;
if (this.TryInvoke(repo => repo.GetConfigInt(key), out value))
{
return value ?? defaultValue;
}

return defaultValue;
}

private LibGit2Repo GetSharedRepo()
{
lock (this.sharedRepoLock)
Expand Down
81 changes: 81 additions & 0 deletions GVFS/GVFS.Common/GitCommandLineParser.cs
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,87 @@ public bool IsCheckoutWithFilePaths()
return false;
}

/// <summary>
/// Determines whether this is a branch-switching checkout/switch and returns
/// the target ref. Returns false for file checkouts, detach, orphan, patch mode,
/// and new branch creation without a start point.
/// </summary>
/// <remarks>
/// Short flag parsing is not fully POSIX-compliant — combined flags like
/// -fb (where -f is a flag and -b takes a value) are treated as a single
/// unknown flag and skipped. The consequence is that auto-dehydrate may
/// run unnecessarily (e.g. before new branch creation at HEAD) or may not
/// run when it could. It will never block or break the checkout.
/// TODO: Use a POSIX-compliant parser (e.g. System.CommandLine) in future.
/// </remarks>
public bool TryGetBranchSwitchTarget(out string targetRef)
{
targetRef = null;

if (!this.IsVerb(Verbs.Checkout) && !this.IsVerb(Verbs.Switch))
{
return false;
}

if (!this.IsValidGitCommand || this.parts.Length <= ArgumentsOffset)
{
return false;
}

if (this.IsCheckoutWithFilePaths())
{
return false;
}

// Bail on patterns that are not branch switches
if (this.HasArgument("--detach") ||
this.HasArgument("--orphan") ||
this.HasArgument("-p") ||
this.HasArgument("--patch"))
{
return false;
}

string candidate = null;

for (int i = ArgumentsOffset; i < this.parts.Length; i++)
{
string arg = this.parts[i];

// -b/-B/-c/-C take a branch name as next arg — skip the name,
// but a start-point after it is the churn-relevant target
if (arg == "-b" || arg == "-B" || arg == "-c" || arg == "-C")
{
i++; // Skip the new branch name
continue;
}

// Skip flags (including --conflict=, --track, -f, etc.)
if (arg.StartsWith("-"))
{
continue;
}

if (candidate == null)
{
candidate = arg;
}
else
{
// Multiple non-flag args without -- means pathspecs are involved
return false;
}
}

if (candidate == null)
{
return false;
}

targetRef = candidate;
return true;
}

public bool IsVerb(Verbs verbs)
{
if (!this.IsValidGitCommand)
Expand Down
11 changes: 11 additions & 0 deletions GVFS/GVFS.Common/GitStatusCache.cs
Original file line number Diff line number Diff line change
Expand Up @@ -422,6 +422,17 @@ private void UpdateHydrationSummary()

bool enabled = TEST_EnableHydrationSummaryOverride
?? this.context.Repository.LibGit2RepoInvoker.GetConfigBoolOrDefault(GVFSConstants.GitConfig.ShowHydrationStatus, GVFSConstants.GitConfig.ShowHydrationStatusDefault);

if (!enabled)
{
// Also enable if any auto-dehydrate threshold is configured
LibGit2RepoInvoker repoInvoker = this.context.Repository.LibGit2RepoInvoker;
enabled =
repoInvoker.GetConfigIntOrDefault(GVFSConstants.GitConfig.DehydrateOnCheckoutPlaceholderPercent, GVFSConstants.GitConfig.DehydrateOnCheckoutDisabled) >= 0 ||
repoInvoker.GetConfigIntOrDefault(GVFSConstants.GitConfig.DehydrateOnCheckoutModifiedPercent, GVFSConstants.GitConfig.DehydrateOnCheckoutDisabled) >= 0 ||
repoInvoker.GetConfigIntOrDefault(GVFSConstants.GitConfig.DehydrateOnCheckoutFolderPercent, GVFSConstants.GitConfig.DehydrateOnCheckoutDisabled) >= 0;
}

if (!enabled)
{
return;
Expand Down
29 changes: 9 additions & 20 deletions GVFS/GVFS.Common/HealthCalculator/EnlistmentHydrationSummary.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using GVFS.Common.FileSystem;
using GVFS.Common.Git;
using GVFS.Common.Tracing;
using System;
using System.Diagnostics;
Expand All @@ -20,6 +21,11 @@ public class EnlistmentHydrationSummary
public int HydratedFileCount => PlaceholderFileCount + ModifiedFileCount;
public int HydratedFolderCount => PlaceholderFolderCount + ModifiedFolderCount;

public int PlaceholderFilePercent => TotalFileCount <= 0 ? 0 : (int)((100L * PlaceholderFileCount) / TotalFileCount);
public int ModifiedFilePercent => TotalFileCount <= 0 ? 0 : (int)((100L * ModifiedFileCount) / TotalFileCount);
public int FileHydrationPercent => TotalFileCount <= 0 ? 0 : (int)((100L * HydratedFileCount) / TotalFileCount);
public int FolderHydrationPercent => TotalFolderCount <= 0 ? 0 : (int)((100L * HydratedFolderCount) / TotalFolderCount);


public bool IsValid
{
Expand Down Expand Up @@ -185,27 +191,10 @@ private static void EmitDurationTelemetry(
/// </summary>
internal static int GetIndexFileCount(GVFSEnlistment enlistment, PhysicalFileSystem fileSystem)
{
string indexPath = enlistment.GitIndexPath;
using (var indexFile = fileSystem.OpenFileStream(indexPath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite, callFlushFileBuffers: false))
using (Stream indexFile = fileSystem.OpenFileStream(
enlistment.GitIndexPath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite, callFlushFileBuffers: false))
{
if (indexFile.Length < 12)
{
return -1;
}
/* The number of files in the index is a big-endian integer from
* the 4 bytes at offsets 8-11 of the index file. */
indexFile.Position = 8;
var bytes = new byte[4];
indexFile.Read(
bytes, // Destination buffer
offset: 0, // Offset in destination buffer, not in indexFile
count: 4);
if (BitConverter.IsLittleEndian)
{
Array.Reverse(bytes);
}
int count = BitConverter.ToInt32(bytes, 0);
return count;
return GitIndexHelper.ReadEntryCount(indexFile);
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
using GVFS.FunctionalTests.FileSystemRunners;
using GVFS.FunctionalTests.Should;
using GVFS.FunctionalTests.Tools;
using GVFS.Tests.Should;
using NUnit.Framework;
using System.IO;

namespace GVFS.FunctionalTests.Tests.EnlistmentPerFixture
{
/// <summary>
/// Single critical-path functional test for auto-dehydrate on checkout.
/// Verifies that when a hydration threshold is configured and exceeded,
/// a branch-switching checkout triggers automatic dehydration.
/// Threshold logic is covered in unit tests.
/// </summary>
[TestFixture]
[Category(Categories.ExtraCoverage)]
public class AutoDehydrateOnCheckoutTests : TestsWithEnlistmentPerFixture
{
private const string BranchA = "FunctionalTests/20201014";
private const string BranchB = "FunctionalTests/20201014_CheckoutTests2";
private FileSystemRunner fileSystem;

public AutoDehydrateOnCheckoutTests()
: base(forcePerRepoObjectCache: true)
{
this.fileSystem = new SystemIORunner();
}

[OneTimeSetUp]
public override void CreateEnlistment()
{
base.CreateEnlistment();

// Fetch BranchB with explicit refspec to create a remote tracking ref
GitHelpers.InvokeGitAgainstGVFSRepo(
this.Enlistment.RepoRoot,
$"fetch origin refs/heads/{BranchB}:refs/remotes/origin/{BranchB}");
}

[TearDown]
public void TearDown()
{
string backupFolder = Path.Combine(this.Enlistment.EnlistmentRoot, "dehydrate_backup");
if (this.fileSystem.DirectoryExists(backupFolder))
{
this.fileSystem.DeleteDirectory(backupFolder);
}

GitHelpers.InvokeGitAgainstGVFSRepo(
this.Enlistment.RepoRoot,
"config --unset gvfs.auto-dehydrate-modified-percent");

if (!this.Enlistment.IsMounted())
{
this.Enlistment.MountGVFS();
}
}

[TestCase]
public void CheckoutDehydratesWhenThresholdExceeded()
{
// Set a low modified threshold so any hydration triggers dehydrate
GitHelpers.InvokeGitAgainstGVFSRepo(
this.Enlistment.RepoRoot,
"config gvfs.auto-dehydrate-modified-percent 0");

// Hydrate files by reading, then delete+restore to push into modified paths
this.HydrateAndModifyFiles();

// Warm the hydration cache by running git status (triggers async
// cache rebuild in mount), then wait for it to populate
GitHelpers.InvokeGitAgainstGVFSRepo(this.Enlistment.RepoRoot, "status");
System.Threading.Thread.Sleep(5000);

ProcessResult result = GitHelpers.InvokeGitAgainstGVFSRepo(
this.Enlistment.RepoRoot,
"checkout " + BranchB);
result.ExitCode.ShouldEqual(0, "checkout failed: " + result.Errors);
string allOutput = result.Output + result.Errors;
allOutput.ShouldContain("Dehydrating before checkout");
}

private void HydrateAndModifyFiles()
{
string[] filesToModify = new[]
{
"Readme.md",
"GVFS/GVFS.sln",
"GVFS/GVFS/Program.cs",
};

foreach (string file in filesToModify)
{
string path = this.Enlistment.GetVirtualPathTo(file);
if (File.Exists(path))
{
File.ReadAllText(path);
File.Delete(path);
}
}

GitHelpers.InvokeGitAgainstGVFSRepo(
this.Enlistment.RepoRoot,
"checkout -- Readme.md GVFS/GVFS.sln GVFS/GVFS/Program.cs");
}
}
}
Loading