| File: Donor\ProjectDataDonorIndex.cs | Web Access |
| Project: Microsoft.NET.ProjectData.csproj (Microsoft.NET.ProjectData) |
// Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. using System.Runtime.InteropServices; using System.Text.Json; namespace Microsoft.NET.ProjectData; /// <summary> /// Reads and writes the repo-scoped donor index used to bootstrap ProjectData caches across sibling worktrees. /// </summary> public static partial class ProjectDataDonorIndex { private const int CurrentVersion = 2; private const string IndexDirectoryName = "dotnet-projectdata"; private const string IndexFileName = "lscache-donor-index.json"; private static readonly TimeSpan IndexLockTimeout = TimeSpan.FromSeconds(10); private static readonly object WriteGate = new(); // Keep these semantics inside the source-linked donor implementation until Tasks targets net11.0 // and can reference Microsoft.NET.ProjectData instead of compiling its own copy. internal static StringComparer PathComparer { get; } = RuntimeInformation.IsOSPlatform(OSPlatform.Linux) ? StringComparer.Ordinal : StringComparer.OrdinalIgnoreCase; private static StringComparison PathComparison { get; } = RuntimeInformation.IsOSPlatform(OSPlatform.Linux) ? StringComparison.Ordinal : StringComparison.OrdinalIgnoreCase; public static bool TryRecordWrite( string projectFilePath, string cacheFilePath, ProjectDataDonorWriteOptions? options, out string? message) { message = null; if (string.IsNullOrEmpty(projectFilePath) || string.IsNullOrEmpty(cacheFilePath) || !File.Exists(cacheFilePath)) { return false; } options ??= new ProjectDataDonorWriteOptions(); if (!options.Enabled) { return false; } try { if (!TryResolveIndexContext( projectFilePath, options.WorkspaceRoot, options.IndexPath, out string workspaceRoot, out string indexPath)) { return false; } lock (WriteGate) { using FileStream indexLock = AcquireIndexLock(indexPath); ProjectDataDonorIndexFile index = ReadIndexForWrite(indexPath, out string? recoveryMessage); ProjectDataDonorIndexEntry entry = CreateWrittenEntry(workspaceRoot, cacheFilePath); index.UpsertEntry(entry); WriteIndex(indexPath, index); OnIndexUpdated(); message = recoveryMessage; } return true; } catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or JsonException or InvalidOperationException or ArgumentException or NotSupportedException) { message = ex.Message; return false; } } private static FileStream AcquireIndexLock(string indexPath) { string lockPath = indexPath + ".lock"; Directory.CreateDirectory(Path.GetDirectoryName(lockPath)!); System.Diagnostics.Stopwatch stopwatch = System.Diagnostics.Stopwatch.StartNew(); while (true) { try { return new FileStream(lockPath, FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.None, bufferSize: 1); } catch (IOException) when (stopwatch.Elapsed < IndexLockTimeout) { Thread.Sleep(25); } catch (IOException ex) { throw new IOException($"Timed out waiting for exclusive access to donor index '{indexPath}'.", ex); } } } internal static string? TryResolveDefaultIndexPath(string projectFilePath) { return TryFindRepositoryInfo(projectFilePath, out _, out string? gitCommonDirectory) ? Path.Combine(gitCommonDirectory, IndexDirectoryName, IndexFileName) : null; } private static bool TryResolveIndexContext( string projectFilePath, string? workspaceRootOverride, string? indexPathOverride, out string workspaceRoot, out string indexPath) { workspaceRoot = string.Empty; indexPath = string.Empty; string? candidateWorkspaceRoot = workspaceRootOverride is { Length: > 0 } ? NormalizePath(workspaceRootOverride) : null; string? candidateIndexPath = indexPathOverride is { Length: > 0 } ? Path.GetFullPath(indexPathOverride) : null; if (candidateWorkspaceRoot is null || candidateIndexPath is null) { if (!TryFindRepositoryInfo(projectFilePath, out string? discoveredWorkspaceRoot, out string? gitCommonDirectory)) { return false; } candidateWorkspaceRoot ??= discoveredWorkspaceRoot; candidateIndexPath ??= Path.Combine(gitCommonDirectory, IndexDirectoryName, IndexFileName); } workspaceRoot = NormalizePath(candidateWorkspaceRoot); indexPath = candidateIndexPath; return true; } private static bool TryGetRelativePath(string root, string fullPath, out string relativePath) { relativePath = string.Empty; string normalizedRoot = EnsureTrailingDirectorySeparator(Path.GetFullPath(root)); string normalizedFullPath = Path.GetFullPath(fullPath); if (!normalizedFullPath.StartsWith(normalizedRoot, PathComparison)) { return false; } relativePath = normalizedFullPath.Substring(normalizedRoot.Length); return relativePath.Length > 0 && !relativePath.StartsWith(".." + Path.DirectorySeparatorChar, StringComparison.Ordinal); } private static string EnsureTrailingDirectorySeparator(string path) { if (path.EndsWith(Path.DirectorySeparatorChar.ToString(), StringComparison.Ordinal) || path.EndsWith(Path.AltDirectorySeparatorChar.ToString(), StringComparison.Ordinal)) { return path; } return path + Path.DirectorySeparatorChar; } private static bool PathsEqual(string left, string right) => string.Equals(NormalizePath(left), NormalizePath(right), PathComparison); private static string NormalizePath(string path) { string fullPath = Path.GetFullPath(path); string root = Path.GetPathRoot(fullPath) ?? string.Empty; return fullPath.Length > root.Length ? fullPath.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar) : fullPath; } private static ProjectDataDonorIndexEntry CreateWrittenEntry( string workspaceRoot, string cacheFilePath) { return new ProjectDataDonorIndexEntry { Path = NormalizePath(workspaceRoot), NewestMtimeMs = ToUnixTimeMilliseconds(File.GetLastWriteTimeUtc(cacheFilePath)), UpdatedUtc = DateTimeOffset.UtcNow, }; } private static bool TryFindRepositoryInfo(string projectFilePath, out string workspaceRoot, out string gitCommonDirectory) { workspaceRoot = string.Empty; gitCommonDirectory = string.Empty; string? current = Path.GetDirectoryName(Path.GetFullPath(projectFilePath)); while (!string.IsNullOrEmpty(current)) { string currentDirectory = current!; string gitPath = Path.Combine(currentDirectory, ".git"); if (Directory.Exists(gitPath)) { workspaceRoot = currentDirectory; gitCommonDirectory = gitPath; return true; } if (File.Exists(gitPath) && TryReadGitFile(gitPath, out string gitDirectory)) { workspaceRoot = currentDirectory; gitCommonDirectory = ResolveCommonGitDirectory(gitDirectory); return true; } current = Directory.GetParent(current)?.FullName; } return false; } private static bool TryReadGitFile(string gitFilePath, out string gitDirectory) { gitDirectory = string.Empty; try { string content = File.ReadAllText(gitFilePath).Trim(); const string Prefix = "gitdir:"; if (!content.StartsWith(Prefix, StringComparison.OrdinalIgnoreCase)) { return false; } string value = content.Substring(Prefix.Length).Trim(); gitDirectory = Path.IsPathRooted(value) ? Path.GetFullPath(value) : Path.GetFullPath(Path.Combine(Path.GetDirectoryName(gitFilePath)!, value)); return true; } catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) { return false; } } private static string ResolveCommonGitDirectory(string gitDirectory) { string commonDirFilePath = Path.Combine(gitDirectory, "commondir"); try { if (File.Exists(commonDirFilePath)) { string commonDir = File.ReadAllText(commonDirFilePath).Trim(); return Path.IsPathRooted(commonDir) ? Path.GetFullPath(commonDir) : Path.GetFullPath(Path.Combine(gitDirectory, commonDir)); } } catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) { } return gitDirectory; } private static long ToUnixTimeMilliseconds(DateTime dateTime) => new DateTimeOffset(DateTime.SpecifyKind(dateTime, DateTimeKind.Utc)).ToUnixTimeMilliseconds(); static partial void OnIndexUpdated(); }