Skip to content

Commit 474a50d

Browse files
committed
Add functional tests for clone with manufactured prefetch state
Two tests that manufacture specific shared-cache states to verify clone behavior when prefetch packs exist: 1. SecondCloneSucceedsWithMissingTreesAndPrefetchPacks: commit and root tree are present as loose objects, a fake prefetch pack exists, but subtrees are missing. Verifies the checkout fallback re-downloads the commit pack. 2. SecondCloneWithPrefetchPacksButMissingCommit: the target commit is completely absent but a fake prefetch pack exists. Exercises the deferred-download path (skippedCommitDownload) that falls back through CreateBranchWithUpstream/TryDownloadRootGitAttributes. Both tests create a temp bare repo to run git pack-objects without VFS hooks, producing a minimal prefetch-named pack in the shared cache's pack directory. Assisted-by: Claude Opus 4.6 Signed-off-by: Tyrie Vella <tyrielv@gmail.com>
1 parent c06020d commit 474a50d

4 files changed

Lines changed: 232 additions & 43 deletions

File tree

GVFS/GVFS.Common/Git/GitObjects.cs

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,40 @@ public virtual void DeleteStaleTempPrefetchPackAndIdxs()
107107
}
108108
}
109109

110+
/// <summary>
111+
/// Returns true if the pack at <paramref name="packPath"/> is a usable prefetch pack:
112+
/// it has a matching .idx file and no .incomplete marker.
113+
/// </summary>
114+
public bool IsUsablePrefetchPack(string packPath)
115+
{
116+
string idxPath = Path.ChangeExtension(packPath, ".idx");
117+
string incompletePath = Path.ChangeExtension(packPath, GVFSConstants.InProgressPrefetchMarkerExtension);
118+
119+
return this.fileSystem.FileExists(idxPath)
120+
&& !this.fileSystem.FileExists(incompletePath);
121+
}
122+
123+
/// <summary>
124+
/// Returns true if at least one usable prefetch pack exists in the pack root.
125+
/// A usable pack has a matching .idx and no .incomplete marker.
126+
/// </summary>
127+
public bool HasUsablePrefetchPacks()
128+
{
129+
string[] prefetchPacks = this.ReadPackFileNames(
130+
this.Enlistment.GitPackRoot,
131+
GVFSConstants.PrefetchPackPrefix);
132+
133+
foreach (string packPath in prefetchPacks)
134+
{
135+
if (this.IsUsablePrefetchPack(packPath))
136+
{
137+
return true;
138+
}
139+
}
140+
141+
return false;
142+
}
143+
110144
private void DeleteStaleIncompletePrefetchPackAndIdxs()
111145
{
112146
string[] packFiles = this.ReadPackFileNames(this.Enlistment.GitPackRoot, GVFSConstants.PrefetchPackPrefix);

GVFS/GVFS.Common/Maintenance/PrefetchStep.cs

Lines changed: 21 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -174,33 +174,35 @@ private bool TryGetMaxGoodPrefetchTimestamp(out long maxGoodTimestamp, out strin
174174
{
175175
long timestamp = orderedPacks[i].Timestamp;
176176
string packPath = orderedPacks[i].Path;
177-
string idxPath = Path.ChangeExtension(packPath, ".idx");
178-
if (!this.Context.FileSystem.FileExists(idxPath))
177+
178+
if (this.GitObjects.IsUsablePrefetchPack(packPath))
179179
{
180-
EventMetadata metadata = this.CreateEventMetadata();
181-
metadata.Add("pack", packPath);
182-
metadata.Add("idxPath", idxPath);
183-
metadata.Add("timestamp", timestamp);
184-
GitProcess.Result indexResult = this.RunGitCommand(process => this.GitObjects.IndexPackFile(packPath, process), nameof(this.GitObjects.IndexPackFile));
180+
maxGoodTimestamp = timestamp;
181+
continue;
182+
}
185183

186-
if (indexResult.ExitCodeIsFailure)
187-
{
188-
firstBadPack = i;
184+
// Pack has no .incomplete marker (filtered above) but is missing its .idx.
185+
// Try to regenerate the index.
186+
string idxPath = Path.ChangeExtension(packPath, ".idx");
187+
EventMetadata metadata = this.CreateEventMetadata();
188+
metadata.Add("pack", packPath);
189+
metadata.Add("idxPath", idxPath);
190+
metadata.Add("timestamp", timestamp);
191+
GitProcess.Result indexResult = this.RunGitCommand(process => this.GitObjects.IndexPackFile(packPath, process), nameof(this.GitObjects.IndexPackFile));
189192

190-
this.Context.Tracer.RelatedWarning(metadata, $"{nameof(this.TryGetMaxGoodPrefetchTimestamp)}: Found pack file that's missing idx file, and failed to regenerate idx");
191-
break;
192-
}
193-
else
194-
{
195-
maxGoodTimestamp = timestamp;
193+
if (indexResult.ExitCodeIsFailure)
194+
{
195+
firstBadPack = i;
196196

197-
metadata.Add(TracingConstants.MessageKey.InfoMessage, $"{nameof(this.TryGetMaxGoodPrefetchTimestamp)}: Found pack file that's missing idx file, and regenerated idx");
198-
this.Context.Tracer.RelatedEvent(EventLevel.Informational, $"{nameof(this.TryGetMaxGoodPrefetchTimestamp)}_RebuildIdx", metadata);
199-
}
197+
this.Context.Tracer.RelatedWarning(metadata, $"{nameof(this.TryGetMaxGoodPrefetchTimestamp)}: Found pack file that's missing idx file, and failed to regenerate idx");
198+
break;
200199
}
201200
else
202201
{
203202
maxGoodTimestamp = timestamp;
203+
204+
metadata.Add(TracingConstants.MessageKey.InfoMessage, $"{nameof(this.TryGetMaxGoodPrefetchTimestamp)}: Found pack file that's missing idx file, and regenerated idx");
205+
this.Context.Tracer.RelatedEvent(EventLevel.Informational, $"{nameof(this.TryGetMaxGoodPrefetchTimestamp)}_RebuildIdx", metadata);
204206
}
205207
}
206208

GVFS/GVFS.FunctionalTests/Tests/MultiEnlistmentTests/SharedCacheTests.cs

Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
using System.IO;
99
using System.Linq;
1010
using System.Runtime.InteropServices;
11+
using System.Text;
1112
using System.Threading;
1213
using System.Threading.Tasks;
1314

@@ -283,6 +284,84 @@ public void SecondCloneSucceedsWithMissingTrees()
283284
File.ReadAllText(Path.Combine(enlistment2.RepoRoot, WellKnownFile));
284285
}
285286

287+
[TestCase]
288+
public void SecondCloneSucceedsWithMissingTreesAndPrefetchPacks()
289+
{
290+
// Scenario: prefetch packs exist in shared cache, the target commit and
291+
// root tree are present as loose objects, but subtrees are missing.
292+
// The checkout fallback must re-download the commit pack.
293+
string newCachePath = Path.Combine(this.localCacheParentPath, ".customGvfsCache3");
294+
GVFSFunctionalTestEnlistment enlistment1 = this.CreateNewEnlistment(localCacheRoot: newCachePath);
295+
this.AlternatesFileShouldHaveGitObjectsRoot(enlistment1);
296+
297+
// While mounted, force-download only the commit and root tree for
298+
// WellKnownBranch. The VFS read-object hook saves these as loose
299+
// objects in the shared cache.
300+
string command = "cat-file -p origin/" + WellKnownBranch + "^{tree}";
301+
ProcessResult result = GitHelpers.InvokeGitAgainstGVFSRepo(enlistment1.RepoRoot, command);
302+
result.ExitCode.ShouldEqual(0, $"git {command} failed with error: " + result.Errors);
303+
304+
string packRoot = enlistment1.GetPackRoot(this.fileSystem);
305+
string objectRoot = enlistment1.GetObjectRoot(this.fileSystem);
306+
307+
// Get a known object SHA to seed the fake prefetch pack (use default
308+
// branch tip — it's already in the cache as a loose object).
309+
string seedSha = GitProcess.Invoke(enlistment1.RepoRoot, "rev-parse HEAD").Trim();
310+
311+
enlistment1.UnmountGVFS();
312+
313+
// Create the fake prefetch pack BEFORE deleting real packs, so that
314+
// pack-objects can find the seed object in the existing packs.
315+
this.CreateMinimalPrefetchPack(enlistment1.RepoRoot, packRoot, seedSha);
316+
317+
// Surgery: delete all NON-fake packs + MIDX so subtrees are gone, but
318+
// leave loose objects (which include WellKnownBranch commit + root tree).
319+
this.DeletePackFilesExcept(packRoot, prefix: "prefetch-9999999999");
320+
this.DeleteMultiPackIndex(packRoot);
321+
322+
// Clone2 on WellKnownBranch: CommitAndRootTreeExists → true (loose),
323+
// HasUsablePrefetchPacks → true, but subtrees are missing.
324+
// The checkout fallback must detect "unable to read tree" and re-download.
325+
GVFSFunctionalTestEnlistment enlistment2 = this.CreateNewEnlistment(localCacheRoot: newCachePath, branch: WellKnownBranch, skipPrefetch: true);
326+
File.ReadAllText(Path.Combine(enlistment2.RepoRoot, WellKnownFile));
327+
}
328+
329+
[TestCase]
330+
public void SecondCloneWithPrefetchPacksButMissingCommit()
331+
{
332+
// Scenario: prefetch packs exist in the shared cache but the target
333+
// commit is NOT present. This exercises the deferred-download path
334+
// (skippedCommitDownload) added by the clone optimization.
335+
string newCachePath = Path.Combine(this.localCacheParentPath, ".customGvfsCache4");
336+
GVFSFunctionalTestEnlistment enlistment1 = this.CreateNewEnlistment(localCacheRoot: newCachePath);
337+
this.AlternatesFileShouldHaveGitObjectsRoot(enlistment1);
338+
339+
string packRoot = enlistment1.GetPackRoot(this.fileSystem);
340+
341+
// Get a known object SHA to seed the fake prefetch pack.
342+
string seedSha = GitProcess.Invoke(enlistment1.RepoRoot, "rev-parse HEAD").Trim();
343+
344+
enlistment1.UnmountGVFS();
345+
346+
// Create the fake prefetch pack BEFORE deleting real packs, so that
347+
// pack-objects can find the seed object in the existing packs.
348+
this.CreateMinimalPrefetchPack(enlistment1.RepoRoot, packRoot, seedSha);
349+
350+
// Surgery: delete all NON-fake packs and MIDX. The real prefetch packs
351+
// contain WellKnownBranch's commit; removing them ensures it's absent.
352+
// Leave loose objects and our fake prefetch pack.
353+
this.DeletePackFilesExcept(packRoot, prefix: "prefetch-9999999999");
354+
this.DeleteMultiPackIndex(packRoot);
355+
356+
// Clone2 on WellKnownBranch: CommitAndRootTreeExists → false,
357+
// HasUsablePrefetchPacks → true, skippedCommitDownload = true.
358+
// CreateBranchWithUpstream or TryDownloadRootGitAttributes will fail
359+
// and trigger the deferred commit download.
360+
GVFSFunctionalTestEnlistment enlistment2 = this.CreateNewEnlistment(localCacheRoot: newCachePath, branch: WellKnownBranch, skipPrefetch: true);
361+
enlistment2.Status().ShouldContain("Mount status: Ready");
362+
File.ReadAllText(Path.Combine(enlistment2.RepoRoot, WellKnownFile));
363+
}
364+
286365
// Override OnTearDownEnlistmentsDeleted rathern than using [TearDown] as the enlistments need to be unmounted before
287366
// localCacheParentPath can be deleted (as the SQLite blob sizes database cannot be deleted while GVFS is mounted)
288367
protected override void OnTearDownEnlistmentsDeleted()
@@ -323,5 +402,102 @@ private void HydrateEntireRepo(GVFSFunctionalTestEnlistment enlistment)
323402
}
324403
}
325404
}
405+
406+
/// <summary>
407+
/// Deletes all .pack, .idx, .keep, .rev, .bitmap, .incomplete files from the
408+
/// given pack directory EXCEPT those whose file name starts with the given prefix.
409+
/// </summary>
410+
private void DeletePackFilesExcept(string packRoot, string prefix)
411+
{
412+
foreach (string file in Directory.EnumerateFiles(packRoot))
413+
{
414+
string fileName = Path.GetFileName(file);
415+
if (fileName.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
416+
{
417+
continue;
418+
}
419+
420+
string ext = Path.GetExtension(file);
421+
if (ext.Equals(".pack", StringComparison.OrdinalIgnoreCase)
422+
|| ext.Equals(".idx", StringComparison.OrdinalIgnoreCase)
423+
|| ext.Equals(".keep", StringComparison.OrdinalIgnoreCase)
424+
|| ext.Equals(".rev", StringComparison.OrdinalIgnoreCase)
425+
|| ext.Equals(".bitmap", StringComparison.OrdinalIgnoreCase)
426+
|| ext.Equals(".incomplete", StringComparison.OrdinalIgnoreCase))
427+
{
428+
File.SetAttributes(file, FileAttributes.Normal);
429+
File.Delete(file);
430+
}
431+
}
432+
}
433+
434+
/// <summary>
435+
/// Deletes the multi-pack-index and related files from the pack directory.
436+
/// </summary>
437+
private void DeleteMultiPackIndex(string packRoot)
438+
{
439+
foreach (string file in Directory.EnumerateFiles(packRoot))
440+
{
441+
if (Path.GetFileName(file).StartsWith("multi-pack-index", StringComparison.OrdinalIgnoreCase))
442+
{
443+
File.Delete(file);
444+
}
445+
}
446+
}
447+
448+
/// <summary>
449+
/// Creates a minimal prefetch-named pack containing a single object.
450+
/// Uses a temporary bare repo to run git pack-objects without VFS hooks.
451+
/// </summary>
452+
private void CreateMinimalPrefetchPack(string repoRoot, string packRoot, string objectSha)
453+
{
454+
// Create a temporary bare repo that borrows objects from the enlistment
455+
// via alternates, so pack-objects can find the seed object.
456+
string tempBareRepo = Path.Combine(Path.GetTempPath(), "gvfs_fakeprefetch_" + Guid.NewGuid().ToString("N").Substring(0, 8));
457+
try
458+
{
459+
Directory.CreateDirectory(tempBareRepo);
460+
GitProcess.Invoke(tempBareRepo, "init --bare .");
461+
462+
// Point alternates at the enlistment's .git/objects and the shared cache
463+
string alternatesPath = Path.Combine(tempBareRepo, "objects", "info", "alternates");
464+
string enlistmentObjects = Path.Combine(repoRoot, ".git", "objects");
465+
string alternatesContent = Path.Combine(enlistmentObjects, "info", "alternates");
466+
string sharedCacheRoot = File.Exists(alternatesContent) ? File.ReadAllText(alternatesContent).Trim() : "";
467+
468+
string alternatesLines = enlistmentObjects;
469+
if (!string.IsNullOrEmpty(sharedCacheRoot))
470+
{
471+
alternatesLines += "\n" + sharedCacheRoot;
472+
}
473+
474+
File.WriteAllText(alternatesPath, alternatesLines);
475+
476+
// Use pack-objects to create a pack with just the seed object.
477+
// The prefix includes a fake timestamp so the pack matches the
478+
// prefetch-<timestamp>-<hash>.pack naming convention.
479+
string packPrefix = Path.Combine(packRoot, "prefetch-9999999999");
480+
481+
MemoryStream inputStream = new MemoryStream(
482+
Encoding.ASCII.GetBytes(objectSha + "\n"));
483+
484+
ProcessResult packResult = GitProcess.InvokeProcess(
485+
tempBareRepo,
486+
"-c safe.bareRepository=all pack-objects " + packPrefix,
487+
inputStream: inputStream);
488+
packResult.ExitCode.ShouldEqual(0, "git pack-objects failed: " + packResult.Errors);
489+
490+
// pack-objects outputs the pack hash; verify the files were created.
491+
string packHash = packResult.Output.Trim();
492+
string expectedPack = packPrefix + "-" + packHash + ".pack";
493+
string expectedIdx = packPrefix + "-" + packHash + ".idx";
494+
expectedPack.ShouldBeAFile(this.fileSystem);
495+
expectedIdx.ShouldBeAFile(this.fileSystem);
496+
}
497+
finally
498+
{
499+
RepositoryHelpers.DeleteTestDirectory(tempBareRepo);
500+
}
501+
}
326502
}
327503
}

GVFS/GVFS/CommandLine/CloneVerb.cs

Lines changed: 1 addition & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -361,29 +361,6 @@ private static bool IsForceCheckoutErrorCloneFailure(string checkoutError)
361361
return true;
362362
}
363363

364-
private static bool HasUsablePrefetchPacks(
365-
GVFSGitObjects gitObjects,
366-
GVFSEnlistment enlistment,
367-
PhysicalFileSystem fileSystem)
368-
{
369-
string[] prefetchPacks = gitObjects.ReadPackFileNames(
370-
enlistment.GitPackRoot,
371-
GVFSConstants.PrefetchPackPrefix);
372-
373-
foreach (string packPath in prefetchPacks)
374-
{
375-
string idxPath = Path.ChangeExtension(packPath, ".idx");
376-
string incompletePath = Path.ChangeExtension(packPath, GVFSConstants.InProgressPrefetchMarkerExtension);
377-
378-
if (fileSystem.FileExists(idxPath) && !fileSystem.FileExists(incompletePath))
379-
{
380-
return true;
381-
}
382-
}
383-
384-
return false;
385-
}
386-
387364
private Result TryCreateEnlistment(
388365
string fullEnlistmentRootPathParameter,
389366
string normalizedEnlistementRootPath,
@@ -647,7 +624,7 @@ private Result CreateClone(
647624
{
648625
tracer.RelatedInfo("Commit {0} already exists locally, skipping download", commitId);
649626
}
650-
else if (HasUsablePrefetchPacks(gitObjects, enlistment, fileSystem))
627+
else if (gitObjects.HasUsablePrefetchPacks())
651628
{
652629
tracer.RelatedInfo("Prefetch packs found in shared cache but commit {0} is not present; deferring download", commitId);
653630
skippedCommitDownload = true;

0 commit comments

Comments
 (0)