diff --git a/GVFS/GVFS.UnitTests/CommandLine/CacheVerbTests.cs b/GVFS/GVFS.UnitTests/CommandLine/CacheVerbTests.cs new file mode 100644 index 000000000..fb42fe75b --- /dev/null +++ b/GVFS/GVFS.UnitTests/CommandLine/CacheVerbTests.cs @@ -0,0 +1,195 @@ +using GVFS.CommandLine; +using NUnit.Framework; +using System; +using System.Globalization; +using System.IO; + +namespace GVFS.UnitTests.CommandLine +{ + [TestFixture] + public class CacheVerbTests + { + private CacheVerb cacheVerb; + private string testDir; + + [SetUp] + public void Setup() + { + this.cacheVerb = new CacheVerb(); + this.testDir = Path.Combine(Path.GetTempPath(), "CacheVerbTests_" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(this.testDir); + } + + [TearDown] + public void TearDown() + { + if (Directory.Exists(this.testDir)) + { + Directory.Delete(this.testDir, recursive: true); + } + } + + [TestCase(0, "0 bytes")] + [TestCase(512, "512 bytes")] + [TestCase(1023, "1023 bytes")] + [TestCase(1024, "1.0 KB")] + [TestCase(1536, "1.5 KB")] + [TestCase(1048576, "1.0 MB")] + [TestCase(1572864, "1.5 MB")] + [TestCase(1073741824, "1.0 GB")] + [TestCase(1610612736, "1.5 GB")] + [TestCase(10737418240, "10.0 GB")] + public void FormatSizeForUserDisplayReturnsExpectedString(long bytes, string expected) + { + CultureInfo savedCulture = CultureInfo.CurrentCulture; + try + { + CultureInfo.CurrentCulture = CultureInfo.InvariantCulture; + Assert.AreEqual(expected, this.cacheVerb.FormatSizeForUserDisplay(bytes)); + } + finally + { + CultureInfo.CurrentCulture = savedCulture; + } + } + + [TestCase] + public void GetPackSummaryWithNoPacks() + { + string packDir = Path.Combine(this.testDir, "pack"); + Directory.CreateDirectory(packDir); + + this.cacheVerb.GetPackSummary( + packDir, + out int prefetchCount, + out long prefetchSize, + out int otherCount, + out long otherSize, + out long latestTimestamp); + + Assert.AreEqual(0, prefetchCount); + Assert.AreEqual(0, prefetchSize); + Assert.AreEqual(0, otherCount); + Assert.AreEqual(0, otherSize); + Assert.AreEqual(0, latestTimestamp); + } + + [TestCase] + public void GetPackSummaryCategorizesPrefetchAndOtherPacks() + { + string packDir = Path.Combine(this.testDir, "pack"); + Directory.CreateDirectory(packDir); + + this.CreateFileWithSize(Path.Combine(packDir, "prefetch-1000-aabbccdd.pack"), 100); + this.CreateFileWithSize(Path.Combine(packDir, "prefetch-2000-eeff0011.pack"), 200); + this.CreateFileWithSize(Path.Combine(packDir, "pack-abcdef1234567890.pack"), 50); + + this.cacheVerb.GetPackSummary( + packDir, + out int prefetchCount, + out long prefetchSize, + out int otherCount, + out long otherSize, + out long latestTimestamp); + + Assert.AreEqual(2, prefetchCount); + Assert.AreEqual(300, prefetchSize); + Assert.AreEqual(1, otherCount); + Assert.AreEqual(50, otherSize); + Assert.AreEqual(2000, latestTimestamp); + } + + [TestCase] + public void GetPackSummaryIgnoresNonPackFiles() + { + string packDir = Path.Combine(this.testDir, "pack"); + Directory.CreateDirectory(packDir); + + this.CreateFileWithSize(Path.Combine(packDir, "prefetch-1000-aabb.pack"), 100); + this.CreateFileWithSize(Path.Combine(packDir, "prefetch-1000-aabb.idx"), 50); + this.CreateFileWithSize(Path.Combine(packDir, "multi-pack-index"), 10); + + this.cacheVerb.GetPackSummary( + packDir, + out int prefetchCount, + out long prefetchSize, + out int otherCount, + out long otherSize, + out long latestTimestamp); + + Assert.AreEqual(1, prefetchCount); + Assert.AreEqual(100, prefetchSize); + Assert.AreEqual(0, otherCount); + Assert.AreEqual(0, otherSize); + } + + [TestCase] + public void GetPackSummaryHandlesBothGuidAndSHA1HashFormats() + { + string packDir = Path.Combine(this.testDir, "pack"); + Directory.CreateDirectory(packDir); + + // GVFS format: 32-char GUID + this.CreateFileWithSize(Path.Combine(packDir, "prefetch-1000-b8d9efad32194d98894532905daf88ec.pack"), 100); + // Scalar format: 40-char SHA1 + this.CreateFileWithSize(Path.Combine(packDir, "prefetch-2000-9babd9b75521f9caf693b485329d3d5669c88564.pack"), 200); + + this.cacheVerb.GetPackSummary( + packDir, + out int prefetchCount, + out long prefetchSize, + out int otherCount, + out long otherSize, + out long latestTimestamp); + + Assert.AreEqual(2, prefetchCount); + Assert.AreEqual(300, prefetchSize); + Assert.AreEqual(2000, latestTimestamp); + } + + [TestCase] + public void CountLooseObjectsWithNoObjects() + { + int count = this.cacheVerb.CountLooseObjects(this.testDir); + Assert.AreEqual(0, count); + } + + [TestCase] + public void CountLooseObjectsCountsFilesInHexDirectories() + { + Directory.CreateDirectory(Path.Combine(this.testDir, "00")); + File.WriteAllText(Path.Combine(this.testDir, "00", "abc123"), string.Empty); + File.WriteAllText(Path.Combine(this.testDir, "00", "def456"), string.Empty); + + Directory.CreateDirectory(Path.Combine(this.testDir, "ff")); + File.WriteAllText(Path.Combine(this.testDir, "ff", "789abc"), string.Empty); + + int count = this.cacheVerb.CountLooseObjects(this.testDir); + Assert.AreEqual(3, count); + } + + [TestCase] + public void CountLooseObjectsIgnoresNonHexDirectories() + { + // "pack" and "info" are valid directories in a git objects dir but not hex dirs + Directory.CreateDirectory(Path.Combine(this.testDir, "pack")); + File.WriteAllText(Path.Combine(this.testDir, "pack", "somefile"), string.Empty); + + Directory.CreateDirectory(Path.Combine(this.testDir, "info")); + File.WriteAllText(Path.Combine(this.testDir, "info", "somefile"), string.Empty); + + // "ab" is a valid hex dir + Directory.CreateDirectory(Path.Combine(this.testDir, "ab")); + File.WriteAllText(Path.Combine(this.testDir, "ab", "object1"), string.Empty); + + int count = this.cacheVerb.CountLooseObjects(this.testDir); + Assert.AreEqual(1, count); + } + + private void CreateFileWithSize(string path, int size) + { + byte[] data = new byte[size]; + File.WriteAllBytes(path, data); + } + } +} diff --git a/GVFS/GVFS/CommandLine/CacheVerb.cs b/GVFS/GVFS/CommandLine/CacheVerb.cs new file mode 100644 index 000000000..70c8a65fd --- /dev/null +++ b/GVFS/GVFS/CommandLine/CacheVerb.cs @@ -0,0 +1,233 @@ +using CommandLine; +using GVFS.Common; +using GVFS.Common.FileSystem; +using GVFS.Common.Tracing; +using System; +using System.Globalization; +using System.IO; + +namespace GVFS.CommandLine +{ + [Verb(CacheVerb.CacheVerbName, HelpText = "Display information about the GVFS shared object cache")] + public class CacheVerb : GVFSVerb.ForExistingEnlistment + { + private const string CacheVerbName = "cache"; + + public CacheVerb() + { + } + + protected override string VerbName + { + get { return CacheVerbName; } + } + + protected override void Execute(GVFSEnlistment enlistment) + { + using (ITracer tracer = new JsonTracer(GVFSConstants.GVFSEtwProviderName, "CacheVerb")) + { + string localCacheRoot; + string gitObjectsRoot; + this.GetLocalCachePaths(tracer, enlistment, out localCacheRoot, out gitObjectsRoot); + + if (string.IsNullOrWhiteSpace(gitObjectsRoot)) + { + this.ReportErrorAndExit("Could not determine git objects root. Is this a GVFS enlistment with a shared cache?"); + } + + this.Output.WriteLine("Repo URL: " + enlistment.RepoUrl); + this.Output.WriteLine("Cache root: " + (localCacheRoot ?? "(unknown)")); + this.Output.WriteLine("Git objects: " + gitObjectsRoot); + + string packRoot = Path.Combine(gitObjectsRoot, GVFSConstants.DotGit.Objects.Pack.Name); + if (!Directory.Exists(packRoot)) + { + this.Output.WriteLine(); + this.Output.WriteLine("Pack directory not found: " + packRoot); + tracer.RelatedError("Pack directory not found: " + packRoot); + return; + } + + int prefetchPackCount; + long prefetchPackSize; + int otherPackCount; + long otherPackSize; + long latestPrefetchTimestamp; + this.GetPackSummary(packRoot, out prefetchPackCount, out prefetchPackSize, out otherPackCount, out otherPackSize, out latestPrefetchTimestamp); + + int looseObjectCount = this.CountLooseObjects(gitObjectsRoot); + + long totalSize = prefetchPackSize + otherPackSize; + this.Output.WriteLine(); + this.Output.WriteLine("Total pack size: " + this.FormatSizeForUserDisplay(totalSize)); + this.Output.WriteLine("Prefetch packs: " + prefetchPackCount + " (" + this.FormatSizeForUserDisplay(prefetchPackSize) + ")"); + this.Output.WriteLine("Other packs: " + otherPackCount + " (" + this.FormatSizeForUserDisplay(otherPackSize) + ")"); + + if (latestPrefetchTimestamp > 0) + { + try + { + DateTimeOffset latestTime = DateTimeOffset.FromUnixTimeSeconds(latestPrefetchTimestamp).ToLocalTime(); + this.Output.WriteLine("Latest prefetch: " + latestTime.ToString("yyyy-MM-dd HH:mm:ss zzz")); + } + catch (ArgumentOutOfRangeException) + { + tracer.RelatedWarning("Prefetch timestamp out of range: " + latestPrefetchTimestamp); + } + } + + this.Output.WriteLine("Loose objects: " + looseObjectCount.ToString("N0")); + + EventMetadata metadata = new EventMetadata(); + metadata.Add("repoUrl", enlistment.RepoUrl); + metadata.Add("localCacheRoot", localCacheRoot); + metadata.Add("gitObjectsRoot", gitObjectsRoot); + metadata.Add("prefetchPackCount", prefetchPackCount); + metadata.Add("prefetchPackSize", prefetchPackSize); + metadata.Add("otherPackCount", otherPackCount); + metadata.Add("otherPackSize", otherPackSize); + metadata.Add("latestPrefetchTimestamp", latestPrefetchTimestamp); + metadata.Add("looseObjectCount", looseObjectCount); + tracer.RelatedEvent(EventLevel.Informational, "CacheInfo", metadata, Keywords.Telemetry); + } + } + + internal void GetPackSummary( + string packRoot, + out int prefetchPackCount, + out long prefetchPackSize, + out int otherPackCount, + out long otherPackSize, + out long latestPrefetchTimestamp) + { + prefetchPackCount = 0; + prefetchPackSize = 0; + otherPackCount = 0; + otherPackSize = 0; + latestPrefetchTimestamp = 0; + + string[] packFiles = Directory.GetFiles(packRoot, "*.pack"); + + foreach (string packFile in packFiles) + { + long length; + try + { + length = new FileInfo(packFile).Length; + } + catch (IOException) + { + continue; + } + + string fileName = Path.GetFileName(packFile); + + if (fileName.StartsWith(GVFSConstants.PrefetchPackPrefix, StringComparison.OrdinalIgnoreCase)) + { + prefetchPackCount++; + prefetchPackSize += length; + + long? timestamp = this.TryGetPrefetchTimestamp(packFile); + if (timestamp.HasValue && timestamp.Value > latestPrefetchTimestamp) + { + latestPrefetchTimestamp = timestamp.Value; + } + } + else + { + otherPackCount++; + otherPackSize += length; + } + } + } + + internal int CountLooseObjects(string gitObjectsRoot) + { + int looseObjectCount = 0; + + for (int i = 0; i < 256; i++) + { + string hexDir = Path.Combine(gitObjectsRoot, i.ToString("x2")); + if (Directory.Exists(hexDir)) + { + try + { + looseObjectCount += Directory.GetFiles(hexDir).Length; + } + catch (IOException) + { + } + } + } + + return looseObjectCount; + } + + private long? TryGetPrefetchTimestamp(string packPath) + { + string filename = Path.GetFileName(packPath); + string[] parts = filename.Split('-'); + if (parts.Length > 1 && long.TryParse(parts[1], out long timestamp)) + { + return timestamp; + } + + return null; + } + + internal string FormatSizeForUserDisplay(long bytes) + { + if (bytes >= 1L << 30) + { + return string.Format(CultureInfo.CurrentCulture, "{0:F1} GB", bytes / (double)(1L << 30)); + } + + if (bytes >= 1L << 20) + { + return string.Format(CultureInfo.CurrentCulture, "{0:F1} MB", bytes / (double)(1L << 20)); + } + + if (bytes >= 1L << 10) + { + return string.Format(CultureInfo.CurrentCulture, "{0:F1} KB", bytes / (double)(1L << 10)); + } + + return bytes + " bytes"; + } + + private void GetLocalCachePaths(ITracer tracer, GVFSEnlistment enlistment, out string localCacheRoot, out string gitObjectsRoot) + { + localCacheRoot = null; + gitObjectsRoot = null; + + try + { + string error; + if (RepoMetadata.TryInitialize(tracer, Path.Combine(enlistment.EnlistmentRoot, GVFSPlatform.Instance.Constants.DotGVFSRoot), out error)) + { + if (!RepoMetadata.Instance.TryGetLocalCacheRoot(out localCacheRoot, out error)) + { + tracer.RelatedWarning("Failed to read local cache root: " + error); + } + + if (!RepoMetadata.Instance.TryGetGitObjectsRoot(out gitObjectsRoot, out error)) + { + tracer.RelatedWarning("Failed to read git objects root: " + error); + } + } + else + { + this.ReportErrorAndExit("Failed to read repo metadata: " + error); + } + } + catch (Exception e) + { + this.ReportErrorAndExit("Failed to read repo metadata: " + e.Message); + } + finally + { + RepoMetadata.Shutdown(); + } + } + } +} diff --git a/GVFS/GVFS/Program.cs b/GVFS/GVFS/Program.cs index c8fba0235..81d712d52 100644 --- a/GVFS/GVFS/Program.cs +++ b/GVFS/GVFS/Program.cs @@ -22,6 +22,7 @@ public static void Main(string[] args) Type[] verbTypes = new Type[] { typeof(CacheServerVerb), + typeof(CacheVerb), typeof(CloneVerb), typeof(ConfigVerb), typeof(DehydrateVerb),