Skip to content

Commit 7bcfed4

Browse files
committed
Auto-dehydrate before checkout when hydration thresholds exceeded
Pre-command hook detects branch-switching checkouts and triggers automatic dehydration when configured hydration thresholds are exceeded. Flow: 1. Parse checkout/switch args — bail if not a branch switch 2. Read threshold configs via libgit2 — bail if all disabled (-1) 3. Query mount's cached hydration summary via named pipe 4. Compare placeholder/modified/folder percentages (OR logic) 5. Check cached git status (5s timeout) — pass --no-status if clean 6. Run gvfs dehydrate --confirm Config settings (all default to -1 = disabled): gvfs.auto-dehydrate-placeholder-percent gvfs.auto-dehydrate-modified-percent gvfs.auto-dehydrate-folder-percent GitStatusCache.UpdateHydrationSummary now also runs when any auto-dehydrate threshold is configured, so users don't need to separately enable gvfs.show-hydration-status. Lazy<LibGit2RepoInvoker> shared across pre-command hook avoids multiple expensive repo handle initializations. Functional test verifies end-to-end dehydrate trigger. Assisted-by: Claude Opus 4.6 Signed-off-by: Tyrie Vella <tyrielv@gmail.com>
1 parent 190ca1e commit 7bcfed4

4 files changed

Lines changed: 263 additions & 16 deletions

File tree

GVFS/GVFS.Common/GitStatusCache.cs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -422,6 +422,17 @@ private void UpdateHydrationSummary()
422422

423423
bool enabled = TEST_EnableHydrationSummaryOverride
424424
?? this.context.Repository.LibGit2RepoInvoker.GetConfigBoolOrDefault(GVFSConstants.GitConfig.ShowHydrationStatus, GVFSConstants.GitConfig.ShowHydrationStatusDefault);
425+
426+
if (!enabled)
427+
{
428+
// Also enable if any auto-dehydrate threshold is configured
429+
LibGit2RepoInvoker repoInvoker = this.context.Repository.LibGit2RepoInvoker;
430+
enabled =
431+
repoInvoker.GetConfigIntOrDefault(GVFSConstants.GitConfig.DehydrateOnCheckoutPlaceholderPercent, GVFSConstants.GitConfig.DehydrateOnCheckoutDisabled) >= 0 ||
432+
repoInvoker.GetConfigIntOrDefault(GVFSConstants.GitConfig.DehydrateOnCheckoutModifiedPercent, GVFSConstants.GitConfig.DehydrateOnCheckoutDisabled) >= 0 ||
433+
repoInvoker.GetConfigIntOrDefault(GVFSConstants.GitConfig.DehydrateOnCheckoutFolderPercent, GVFSConstants.GitConfig.DehydrateOnCheckoutDisabled) >= 0;
434+
}
435+
425436
if (!enabled)
426437
{
427438
return;
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
using GVFS.FunctionalTests.FileSystemRunners;
2+
using GVFS.FunctionalTests.Should;
3+
using GVFS.FunctionalTests.Tools;
4+
using GVFS.Tests.Should;
5+
using NUnit.Framework;
6+
using System.IO;
7+
8+
namespace GVFS.FunctionalTests.Tests.EnlistmentPerFixture
9+
{
10+
/// <summary>
11+
/// Single critical-path functional test for auto-dehydrate on checkout.
12+
/// Verifies that when a hydration threshold is configured and exceeded,
13+
/// a branch-switching checkout triggers automatic dehydration.
14+
/// Threshold logic is covered in unit tests.
15+
/// </summary>
16+
[TestFixture]
17+
[Category(Categories.ExtraCoverage)]
18+
public class AutoDehydrateOnCheckoutTests : TestsWithEnlistmentPerFixture
19+
{
20+
private const string BranchA = "FunctionalTests/20201014";
21+
private const string BranchB = "FunctionalTests/20201014_CheckoutTests2";
22+
private FileSystemRunner fileSystem;
23+
24+
public AutoDehydrateOnCheckoutTests()
25+
: base(forcePerRepoObjectCache: true)
26+
{
27+
this.fileSystem = new SystemIORunner();
28+
}
29+
30+
[OneTimeSetUp]
31+
public override void CreateEnlistment()
32+
{
33+
base.CreateEnlistment();
34+
35+
// Fetch BranchB with explicit refspec to create a remote tracking ref
36+
GitHelpers.InvokeGitAgainstGVFSRepo(
37+
this.Enlistment.RepoRoot,
38+
$"fetch origin refs/heads/{BranchB}:refs/remotes/origin/{BranchB}");
39+
}
40+
41+
[TearDown]
42+
public void TearDown()
43+
{
44+
string backupFolder = Path.Combine(this.Enlistment.EnlistmentRoot, "dehydrate_backup");
45+
if (this.fileSystem.DirectoryExists(backupFolder))
46+
{
47+
this.fileSystem.DeleteDirectory(backupFolder);
48+
}
49+
50+
GitHelpers.InvokeGitAgainstGVFSRepo(
51+
this.Enlistment.RepoRoot,
52+
"config --unset gvfs.auto-dehydrate-modified-percent");
53+
54+
if (!this.Enlistment.IsMounted())
55+
{
56+
this.Enlistment.MountGVFS();
57+
}
58+
}
59+
60+
[TestCase]
61+
public void CheckoutDehydratesWhenThresholdExceeded()
62+
{
63+
// Set a low modified threshold so any hydration triggers dehydrate
64+
GitHelpers.InvokeGitAgainstGVFSRepo(
65+
this.Enlistment.RepoRoot,
66+
"config gvfs.auto-dehydrate-modified-percent 0");
67+
68+
// Hydrate files by reading, then delete+restore to push into modified paths
69+
this.HydrateAndModifyFiles();
70+
71+
// Warm the hydration cache by running git status (triggers async
72+
// cache rebuild in mount), then wait for it to populate
73+
GitHelpers.InvokeGitAgainstGVFSRepo(this.Enlistment.RepoRoot, "status");
74+
System.Threading.Thread.Sleep(5000);
75+
76+
ProcessResult result = GitHelpers.InvokeGitAgainstGVFSRepo(
77+
this.Enlistment.RepoRoot,
78+
"checkout " + BranchB);
79+
result.ExitCode.ShouldEqual(0, "checkout failed: " + result.Errors);
80+
string allOutput = result.Output + result.Errors;
81+
allOutput.ShouldContain("Dehydrating before checkout");
82+
}
83+
84+
private void HydrateAndModifyFiles()
85+
{
86+
string[] filesToModify = new[]
87+
{
88+
"Readme.md",
89+
"GVFS/GVFS.sln",
90+
"GVFS/GVFS/Program.cs",
91+
};
92+
93+
foreach (string file in filesToModify)
94+
{
95+
string path = this.Enlistment.GetVirtualPathTo(file);
96+
if (File.Exists(path))
97+
{
98+
File.ReadAllText(path);
99+
File.Delete(path);
100+
}
101+
}
102+
103+
GitHelpers.InvokeGitAgainstGVFSRepo(
104+
this.Enlistment.RepoRoot,
105+
"checkout -- Readme.md GVFS/GVFS.sln GVFS/GVFS/Program.cs");
106+
}
107+
}
108+
}

GVFS/GVFS.Hooks/GVFS.Hooks.csproj

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,9 @@
3434
<Compile Include="..\GVFS.Common\Git\LibGit2RepoInvoker.cs">
3535
<Link>Common\Git\LibGit2RepoInvoker.cs</Link>
3636
</Compile>
37+
<Compile Include="..\GVFS.Common\GitCommandLineParser.cs">
38+
<Link>Common\GitCommandLineParser.cs</Link>
39+
</Compile>
3740
<Compile Include="..\GVFS.Common\GVFSConstants.cs">
3841
<Link>Common\GVFSConstants.cs</Link>
3942
</Compile>

GVFS/GVFS.Hooks/Program.cs

Lines changed: 141 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ public partial class Program
2525
private static string enlistmentPipename;
2626
private static string normalizedCurrentDirectory;
2727
private static Random random = new Random();
28+
private static Lazy<LibGit2RepoInvoker> lazyRepoInvoker;
2829

2930
private delegate void LockRequestDelegate(bool unattended, string[] args, int pid, NamedPipeClient pipeClient);
3031

@@ -58,8 +59,24 @@ public static void Main(string[] args)
5859
{
5960
case PreCommandHook:
6061
CheckForLegalCommands(args);
61-
RunLockRequest(args, unattended, AcquireGVFSLockForProcess);
62-
RunPreCommands(args);
62+
lazyRepoInvoker = new Lazy<LibGit2RepoInvoker>(
63+
() => new LibGit2RepoInvoker(NullTracer.Instance, normalizedCurrentDirectory));
64+
try
65+
{
66+
TryDehydrateBeforeCheckout(args);
67+
RunLockRequest(args, unattended, AcquireGVFSLockForProcess);
68+
RunPreCommands(args);
69+
}
70+
finally
71+
{
72+
if (lazyRepoInvoker.IsValueCreated)
73+
{
74+
lazyRepoInvoker.Value.Dispose();
75+
}
76+
77+
lazyRepoInvoker = null;
78+
}
79+
6380
break;
6481

6582
case PostCommandHook:
@@ -125,10 +142,9 @@ private static bool HasShortFlag(string arg, string flag)
125142

126143
private static bool ConfigurationAllowsHydrationStatus()
127144
{
128-
using (LibGit2RepoInvoker repo = new LibGit2RepoInvoker(NullTracer.Instance, normalizedCurrentDirectory))
129-
{
130-
return repo.GetConfigBoolOrDefault(GVFSConstants.GitConfig.ShowHydrationStatus, GVFSConstants.GitConfig.ShowHydrationStatusDefault);
131-
}
145+
return lazyRepoInvoker.Value.GetConfigBoolOrDefault(
146+
GVFSConstants.GitConfig.ShowHydrationStatus,
147+
GVFSConstants.GitConfig.ShowHydrationStatusDefault);
132148
}
133149

134150
/// <summary>
@@ -138,12 +154,29 @@ private static bool ConfigurationAllowsHydrationStatus()
138154
/// </summary>
139155
private static void TryDisplayCachedHydrationStatus()
140156
{
141-
const int HydrationStatusTimeoutMs = 100;
142-
const int ConnectTimeoutMs = 50;
157+
var status = TryGetCachedHydrationStatus();
158+
if (status != null)
159+
{
160+
string message = status.ToDisplayMessage();
161+
if (message != null)
162+
{
163+
Console.WriteLine(message);
164+
}
165+
}
166+
}
167+
168+
/// <summary>
169+
/// Query the mount process for the cached hydration status via named pipe.
170+
/// Returns null on any failure — must never block the caller.
171+
/// </summary>
172+
private static NamedPipeMessages.HydrationStatus.Response TryGetCachedHydrationStatus()
173+
{
174+
const int HydrationStatusTimeoutMs = 5000;
175+
const int ConnectTimeoutMs = 2000;
143176

144177
try
145178
{
146-
Task<string> task = Task.Run(() =>
179+
Task<NamedPipeMessages.HydrationStatus.Response> task = Task.Run(() =>
147180
{
148181
using (NamedPipeClient pipeClient = new NamedPipeClient(enlistmentPipename))
149182
{
@@ -158,24 +191,116 @@ private static void TryDisplayCachedHydrationStatus()
158191
if (response.Header == NamedPipeMessages.HydrationStatus.SuccessResult
159192
&& NamedPipeMessages.HydrationStatus.Response.TryParse(response.Body, out NamedPipeMessages.HydrationStatus.Response status))
160193
{
161-
return status.ToDisplayMessage();
194+
return status;
162195
}
163196

164197
return null;
165198
}
166199
});
167200

168-
// Hard outer timeout — if the task hasn't completed (e.g., ReadResponse
169-
// blocked on a stalled mount process), we abandon it. The orphaned thread
170-
// is cleaned up when the hook process exits immediately after.
171-
if (task.Wait(HydrationStatusTimeoutMs) && task.Status == TaskStatus.RanToCompletion && task.Result != null)
201+
if (task.Wait(HydrationStatusTimeoutMs) && task.Status == TaskStatus.RanToCompletion)
202+
{
203+
return task.Result;
204+
}
205+
}
206+
catch (Exception)
207+
{
208+
// Silently ignore
209+
}
210+
211+
return null;
212+
}
213+
214+
/// <summary>
215+
/// Best-effort dehydrate before a branch-switching checkout.
216+
/// Runs before the GVFS lock is acquired. On any failure,
217+
/// allows the checkout to proceed normally.
218+
/// </summary>
219+
private static void TryDehydrateBeforeCheckout(string[] args)
220+
{
221+
try
222+
{
223+
string fullCommand = GenerateFullCommand(args);
224+
GitCommandLineParser parser = new GitCommandLineParser(fullCommand);
225+
if (!parser.TryGetBranchSwitchTarget(out string targetRef))
226+
{
227+
return;
228+
}
229+
230+
LibGit2RepoInvoker repoInvoker = lazyRepoInvoker.Value;
231+
int placeholderThreshold = repoInvoker.GetConfigIntOrDefault(
232+
GVFSConstants.GitConfig.DehydrateOnCheckoutPlaceholderPercent,
233+
GVFSConstants.GitConfig.DehydrateOnCheckoutDisabled);
234+
int modifiedThreshold = repoInvoker.GetConfigIntOrDefault(
235+
GVFSConstants.GitConfig.DehydrateOnCheckoutModifiedPercent,
236+
GVFSConstants.GitConfig.DehydrateOnCheckoutDisabled);
237+
int folderThreshold = repoInvoker.GetConfigIntOrDefault(
238+
GVFSConstants.GitConfig.DehydrateOnCheckoutFolderPercent,
239+
GVFSConstants.GitConfig.DehydrateOnCheckoutDisabled);
240+
241+
if (placeholderThreshold < 0 && modifiedThreshold < 0 && folderThreshold < 0)
242+
{
243+
return;
244+
}
245+
246+
// Check hydration levels against thresholds via mount's cached summary.
247+
// Bail if we can't reach the mount or no threshold is exceeded.
248+
NamedPipeMessages.HydrationStatus.Response hydration = TryGetCachedHydrationStatus();
249+
if (hydration == null || !hydration.IsValid || hydration.TotalFileCount <= 0)
250+
{
251+
return;
252+
}
253+
254+
bool shouldDehydrate = false;
255+
int placeholderPercent = (int)((100L * hydration.PlaceholderFileCount) / hydration.TotalFileCount);
256+
int modifiedPercent = (int)((100L * hydration.ModifiedFileCount) / hydration.TotalFileCount);
257+
int folderPercent = hydration.TotalFolderCount > 0
258+
? (int)((100L * hydration.HydratedFolderCount) / hydration.TotalFolderCount)
259+
: 0;
260+
261+
if (placeholderThreshold >= 0 && placeholderPercent >= placeholderThreshold)
262+
{
263+
shouldDehydrate = true;
264+
}
265+
266+
if (modifiedThreshold >= 0 && modifiedPercent >= modifiedThreshold)
267+
{
268+
shouldDehydrate = true;
269+
}
270+
271+
if (folderThreshold >= 0 && folderPercent >= folderThreshold)
172272
{
173-
Console.WriteLine(task.Result);
273+
shouldDehydrate = true;
174274
}
275+
276+
if (!shouldDehydrate)
277+
{
278+
return;
279+
}
280+
281+
// Check cached git status to skip the expensive full status scan
282+
// in the dehydrate verb. Timeout after 5s — if status takes too long,
283+
// let dehydrate run its own status check.
284+
string noStatusFlag = string.Empty;
285+
Task<ProcessResult> statusTask = Task.Run(() =>
286+
ProcessHelper.Run("git", "status --porcelain", redirectOutput: true));
287+
if (statusTask.Wait(5000)
288+
&& statusTask.Status == TaskStatus.RanToCompletion
289+
&& statusTask.Result.ExitCode == 0
290+
&& string.IsNullOrWhiteSpace(statusTask.Result.Output))
291+
{
292+
noStatusFlag = " --no-status";
293+
}
294+
295+
Console.WriteLine("Dehydrating before checkout (do not interrupt)...");
296+
ProcessHelper.Run(
297+
"gvfs",
298+
$"dehydrate --confirm{noStatusFlag}",
299+
redirectOutput: false);
175300
}
176301
catch (Exception)
177302
{
178-
// Silently ignore — never block git status for hydration display
303+
// Best-effort: never block the checkout
179304
}
180305
}
181306

0 commit comments

Comments
 (0)