Skip to content
Draft
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
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ This project provides plugin implementations for the experimental [MSBuild Proje

## Usage

This feature requires Visual Studio 17.9 or later.
This feature requires Visual Studio 17.9 or later and assumes the repo uses Git or Unity Version Control.

To enable caching, simply add a `<PackageReference>` for the desired cache implementation and set various properties to configure it.

Expand All @@ -32,7 +32,7 @@ Here is an example if you're using NuGet's [Central Package Management](https://
</ItemGroup>
```

For repos using C++, you will need to add the projects to a packages.config and import the props/targets files directly.
For repos using C++, you will need to add the projects to a packages.config and import the props/targets files directly. For caching to work, the target solution needs to be above its projects.

`Directory.Build.props`:
```xml
Expand Down
Binary file modified src/Common.Tests/SourceControl/GitFileHashProviderTest.cs
Binary file not shown.
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
// Copyright (c) Microsoft. All rights reserved.
// Licensed under the MIT license. See LICENSE file in the project root for full license information.

using System.Collections.Generic;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using BuildXL.Utilities;
using Microsoft.MSBuildCache.SourceControl.UnityVersionControl;
using Microsoft.MSBuildCache.Tests.Mocks;
using Microsoft.VisualStudio.TestTools.UnitTesting;

namespace Microsoft.MSBuildCache.Tests.SourceControl;

[TestClass]
public class UnityVersionControlFileHashProviderTests
{
private static readonly byte[] FakeHash = { 0, 1, 2, 3, 4 };

#pragma warning disable CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider declaring as nullable. Justification: Always set by MSTest
public TestContext TestContext { get; set; }
#pragma warning restore CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider declaring as nullable.

private static Task FakeHasher(List<string> filesToRehash, Dictionary<string, byte[]> fileHashes)
{
foreach (string file in filesToRehash)
{
fileHashes[file] = FakeHash;
}

return Task.CompletedTask;
}

[TestMethod, Timeout(10000)]
public async Task ParseCmLsFiles()
{
const string repoRoot = @"C:\work\MSBuildCacheTest";
// This has two modified and one untracked files
const string lsFilesOutput = "c:\\work\\MSBuildCacheTest\tFZMuOF2WDemh7irROkxyWw==\nc:\\work\\MSBuildCacheTest\\foo.txt\t9nwry/z6MPzLNvctyiKoFw==\nc:\\work\\MSBuildCacheTest\\bar.txt\t";

UnityVersionControlFileHashProvider unityFileHashProvider = new(NullPluginLogger.Instance);
Dictionary<string, byte[]> hashes = await unityFileHashProvider.ParseUnityLsFiles(new StringReader(lsFilesOutput), FakeHasher);
int filesExpected = 3;
Assert.AreEqual(filesExpected, hashes.Count, $"should be {filesExpected} files in this output");
string barPath = Path.Combine(repoRoot, @"bar.txt").ToUpperInvariant();
Assert.AreEqual(FakeHash, hashes[barPath], $"bytes of {barPath} should be {FakeHash} since it should have gotten hashed by the FakeHasher");
Assert.AreEqual("0001020304", hashes[Path.Combine(repoRoot, "bar.txt")].ToHex());
}

[TestMethod, Timeout(10000)]
public async Task ParseRealCmLsFiles()
{
const string repoRoot = @"C:\work\devroot";
UnityVersionControlFileHashProvider unityFileHashProvider = new(NullPluginLogger.Instance);
var dict = await unityFileHashProvider.GetFileHashesAsync(repoRoot, CancellationToken.None);
int filesExpected = 116921;
Assert.AreEqual(filesExpected, dict.Count, $"should be {filesExpected} files in this output");
}
}
58 changes: 42 additions & 16 deletions src/Common/MSBuildCachePluginBase.cs
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
using Microsoft.MSBuildCache.Hashing;
using Microsoft.MSBuildCache.Parsing;
using Microsoft.MSBuildCache.SourceControl;
using Microsoft.MSBuildCache.SourceControl.UnityVersionControl;

namespace Microsoft.MSBuildCache;

Expand All @@ -44,6 +45,18 @@ public abstract class MSBuildCachePluginBase : MSBuildCachePluginBase<PluginSett
public abstract class MSBuildCachePluginBase<TPluginSettings> : ProjectCachePluginBase, IAsyncDisposable
where TPluginSettings : PluginSettings
{
private class RepoInfo
{
public string Root { get; set; }
public VersionControl VersionControl { get; set; }

public RepoInfo(string root, VersionControl versionControl)
{
Root = root;
VersionControl = versionControl;
}
}
enum VersionControl { Git, UVCS };
private static readonly string PluginAssemblyDirectory = Path.GetDirectoryName(typeof(MSBuildCachePluginBase<TPluginSettings>).Assembly.Location)!;

private static readonly SemaphoreSlim SinglePluginInstanceLock = new(1, 1);
Expand All @@ -53,6 +66,7 @@ public abstract class MSBuildCachePluginBase<TPluginSettings> : ProjectCachePlug

private string? _repoRoot;
private string? _buildId;
private VersionControl? _versionControl;

// Set if we've received any file access report. Ideally MSBuild would tell us in the CacheContext
private bool _hasHadFileAccessReport;
Expand Down Expand Up @@ -214,12 +228,13 @@ public override Task BeginBuildAsync(CacheContext context, PluginLoggerBase logg
private async Task BeginBuildInnerAsync(CacheContext context, PluginLoggerBase logger, CancellationToken cancellationToken)
{
_pluginLogger = logger;

_repoRoot = GetRepoRoot(context, logger);
if (_repoRoot == null)
RepoInfo? repoInfo = GetRepoInfo(context, logger);
if (repoInfo == null)
{
return;
}
_repoRoot = repoInfo.Root;
_versionControl = repoInfo.VersionControl;

_buildId = GetBuildId();

Expand Down Expand Up @@ -1033,8 +1048,9 @@ private bool TryAcquireLock(PluginSettings settings, PluginLoggerBase logger)
return true;
}

private static string? GetRepoRoot(CacheContext context, PluginLoggerBase logger)
private static RepoInfo? GetRepoInfo(CacheContext context, PluginLoggerBase logger)
{
RepoInfo? repoInfo = null;
IEnumerable<string> projectFilePaths = context.Graph != null
? context.Graph.EntryPointNodes.Select(node => node.ProjectInstance.FullPath)
: context.GraphEntryPoints != null
Expand All @@ -1044,12 +1060,10 @@ private bool TryAcquireLock(PluginSettings settings, PluginLoggerBase logger)
HashSet<string> repoRoots = new(StringComparer.OrdinalIgnoreCase);
foreach (string projectFilePath in projectFilePaths)
{
string? repoRoot = GetRepoRootInternal(Path.GetDirectoryName(projectFilePath)!);

// Tolerate projects which aren't under any git repo.
if (repoRoot != null)
repoInfo = GetRepoInfoInternal(Path.GetDirectoryName(projectFilePath)!, logger);
if (repoInfo != null)
{
repoRoots.Add(repoRoot);
repoRoots.Add(repoInfo.Root);
}
}

Expand All @@ -1063,23 +1077,29 @@ private bool TryAcquireLock(PluginSettings settings, PluginLoggerBase logger)
{
string repoRoot = repoRoots.First();
logger.LogMessage($"Repo root: {repoRoot}");
return repoRoot;
return repoInfo;
}

logger.LogWarning($"Graph contains projects from multiple git repositories. Disabling the cache. Repo roots: {string.Join(", ", repoRoots)}");
return null;

static string? GetRepoRootInternal(string path)
static RepoInfo? GetRepoInfoInternal(string path, PluginLoggerBase logger)
{
// Note: When using git worktrees, .git may be a file instead of a directory.
string gitPath = Path.Combine(path, ".git");
if (Directory.Exists(gitPath) || File.Exists(gitPath))
{
return path;
return new RepoInfo(path, VersionControl.Git);
}

string unityVersionControlPath = Path.Combine(path, ".plastic");
if (Directory.Exists(unityVersionControlPath))
{
return new RepoInfo(path, VersionControl.UVCS);
}

string? parentDir = Path.GetDirectoryName(path);
return parentDir != null ? GetRepoRootInternal(parentDir) : null;
return parentDir != null ? GetRepoInfoInternal(parentDir, logger) : null;
}
}

Expand Down Expand Up @@ -1121,15 +1141,21 @@ void WarnIfCowNotSupportedBetweenRepoRootAndPath(string path, string pathDescrip

private async Task<IReadOnlyDictionary<string, byte[]>> GetSourceControlFileHashesAsync(PluginLoggerBase logger, CancellationToken cancellationToken)
{
if (_repoRoot == null)
if (_repoRoot == null || _versionControl == null)
{
throw new InvalidOperationException($"{nameof(_repoRoot)} was unexpectedly null");
}

logger.LogMessage("Source Control: Getting hashes");
logger.LogMessage($"Source Control: Getting hashes from {_versionControl.Value}");
Stopwatch stopwatch = Stopwatch.StartNew();

GitFileHashProvider hashProvider = new(logger);
ISourceControlFileHashProvider hashProvider = _versionControl switch
{
VersionControl.Git => new GitFileHashProvider(logger),
VersionControl.UVCS => new UnityVersionControlFileHashProvider(logger),
_ => throw new NotImplementedException()
};

IReadOnlyDictionary<string, byte[]> fileHashes = await hashProvider.GetFileHashesAsync(_repoRoot, cancellationToken);
logger.LogMessage($"Source Control: File hashes query took {stopwatch.ElapsedMilliseconds} ms");

Expand Down
49 changes: 49 additions & 0 deletions src/Common/SourceControl/Git.cs
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@

using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using BuildXL.Utilities.Core.Tasks;
using Microsoft.Build.Experimental.ProjectCache;
#if NETFRAMEWORK
using Process = Microsoft.MSBuildCache.SourceControl.GitProcess;
Expand Down Expand Up @@ -95,4 +97,51 @@ static void KillProcess(Process process)
}
}
}

internal static Task HashObjectAsync(string basePath, List<string> filesToRehash, Dictionary<string, byte[]> filehashes, PluginLoggerBase _logger, CancellationToken cancellationToken)
{
return RunAsync(
_logger,
workingDir: basePath,
"hash-object --stdin-paths",
async (stdin, stdout) =>
{
foreach (string file in filesToRehash)
{
string? gitHashOfFile;

if (File.Exists(file))
{
await stdin.WriteLineAsync(file);
gitHashOfFile = await stdout.ReadLineAsync();

if (string.IsNullOrWhiteSpace(gitHashOfFile))
{
_logger.LogMessage($"git hash-object returned an empty string for {file}. Forcing a cache miss by using a Guid");

// Guids are only 32 characters and git hashes are 40. Prepend 8 characters to match and to generally be recognizable.
gitHashOfFile = "bad00000" + Guid.NewGuid().ToString("N");
}
}
else
{
gitHashOfFile = null;
}

filehashes[file] = HexUtilities.HexToBytes(gitHashOfFile);
}

return Unit.Void;
},
(exitCode, result) =>
{
if (exitCode != 0)
{
throw new SourceControlHashException("git hash-object failed with exit code " + exitCode);
}

return result;
},
cancellationToken);
}
}
49 changes: 1 addition & 48 deletions src/Common/SourceControl/GitFileHashProvider.cs
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ private async Task<Dictionary<string, byte[]>> GetModuleFileHashesAsync(string b
_logger,
workingDir: basePath,
"ls-files -z -cmos --exclude-standard",
(_, stdout) => Task.Run(() => ParseGitLsFiles(basePath, stdout, (filesToRehash, fileHashes) => GitHashObjectAsync(basePath, filesToRehash, fileHashes, cancellationToken))),
(_, stdout) => Task.Run(() => ParseGitLsFiles(basePath, stdout, (filesToRehash, fileHashes) => Git.HashObjectAsync(basePath, filesToRehash, fileHashes, _logger, cancellationToken))),
(exitCode, result) =>
{
if (exitCode != 0)
Expand Down Expand Up @@ -193,53 +193,6 @@ internal async Task<Dictionary<string, byte[]>> ParseGitLsFiles(
return fileHashes;
}

internal Task GitHashObjectAsync(string basePath, List<string> filesToRehash, Dictionary<string, byte[]> filehashes, CancellationToken cancellationToken)
{
return Git.RunAsync(
_logger,
workingDir: basePath,
"hash-object --stdin-paths",
async (stdin, stdout) =>
{
foreach (string file in filesToRehash)
{
string? gitHashOfFile;

if (File.Exists(file))
{
await stdin.WriteLineAsync(file);
gitHashOfFile = await stdout.ReadLineAsync();

if (string.IsNullOrWhiteSpace(gitHashOfFile))
{
_logger.LogMessage($"git hash-object returned an empty string for {file}. Forcing a cache miss by using a Guid");

// Guids are only 32 characters and git hashes are 40. Prepend 8 characters to match and to generally be recognizable.
gitHashOfFile = "bad00000" + Guid.NewGuid().ToString("N");
}
}
else
{
gitHashOfFile = null;
}

filehashes[file] = HexUtilities.HexToBytes(gitHashOfFile);
}

return Unit.Void;
},
(exitCode, result) =>
{
if (exitCode != 0)
{
throw new SourceControlHashException("git ls-files failed with exit code " + exitCode);
}

return result;
},
cancellationToken);
}

private Task<List<string>> GetInitializedSubmodulesAsync(string repoRoot, CancellationToken cancellationToken)
{
string stdErrString = string.Empty;
Expand Down
Loading