File: Pipelines\GitPipelineOutputReader.cs
Web Access
Project: src\src\Aspire.Hosting\Aspire.Hosting.csproj (Aspire.Hosting)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using System.Buffers;
using System.Collections.ObjectModel;
using System.ComponentModel;
using System.Diagnostics.CodeAnalysis;
using System.Text;
using Aspire.Hosting.Dcp.Process;
using Microsoft.Extensions.FileSystemGlobbing;
 
namespace Aspire.Hosting.Pipelines;
 
/// <summary>
/// Reads Git membership and metadata and computes hashes for pipeline outputs.
/// </summary>
internal sealed class GitPipelineOutputReader(string repositoryRoot, IProcessRunner processRunner)
{
    private static readonly ReadOnlyDictionary<string, string> s_environmentVariables = new(new Dictionary<string, string> { ["GIT_OPTIONAL_LOCKS"] = "0" });
    private static readonly SearchValues<char> s_hexDigits = SearchValues.Create("0123456789abcdefABCDEF");
    private static readonly SearchValues<char> s_octalDigits = SearchValues.Create("01234567");
    private static readonly SearchValues<char> s_indexTags = SearchValues.Create("HhSsMmRrCcKk?");
    private static readonly SearchValues<char> s_statusCodes = SearchValues.Create(".MADRCUT");
 
    /// <summary>
    /// Creates a reader for the directory's worktree, or returns null when Git or the worktree is unavailable.
    /// </summary>
    internal static async Task<GitPipelineOutputReader?> TryCreateAsync(string directory, IProcessRunner processRunner, CancellationToken cancellationToken)
    {
        try
        {
            var result = await processRunner.RunAndCaptureTextAsync("git",
                ["--no-optional-locks", "rev-parse", "--is-inside-work-tree", "--show-cdup"], directory,
                s_environmentVariables, cancellationToken: cancellationToken).ConfigureAwait(false);
            if (result.ExitCode != 0)
            {
                return null;
            }
 
            var output = result.StandardOutput.AsSpan();
            if (output.StartsWith("false\n", StringComparison.Ordinal) || output.StartsWith("false\r\n", StringComparison.Ordinal))
            {
                return null;
            }
 
            // Inside a worktree: "true\n../../\n" ("true\n\n" at its root).
            // --show-cdup supplies only parent segments. Outside, its unused value can be absolute.
            // https://git-scm.com/docs/git-rev-parse#Documentation/git-rev-parse.txt---show-cdup
            Span<Range> lines = stackalloc Range[3];
            if (output.Split(lines, '\n') != lines.Length || !output[lines[2]].IsEmpty)
            {
                ThrowMalformed("worktree discovery", "lines", output);
            }
 
            var flag = output[lines[0]];
            flag = !flag.IsEmpty && flag[^1] == '\r' ? flag[..^1] : flag;
            var pathToRoot = output[lines[1]];
            pathToRoot = !pathToRoot.IsEmpty && pathToRoot[^1] == '\r' ? pathToRoot[..^1] : pathToRoot;
            var parents = pathToRoot;
            while (parents.StartsWith("../", StringComparison.Ordinal))
            {
                parents = parents[3..];
            }
            if (!flag.SequenceEqual("true") || !parents.IsEmpty)
            {
                ThrowMalformed("worktree discovery", "parent path", output);
            }
 
            return new(Path.GetFullPath(pathToRoot.IsEmpty ? "." : pathToRoot.ToString(), directory), processRunner);
        }
        catch (Win32Exception exception) when (exception.NativeErrorCode == 2) // ERROR_FILE_NOT_FOUND on Windows; ENOENT on Unix.
        {
            return null;
        }
    }
 
    /// <summary>
    /// Selects existing output leaves using Git membership and the ordered matcher rules.
    /// </summary>
    internal async Task<IReadOnlyList<SelectedFile>> SelectFilesAsync(string directory, Matcher matcher, CancellationToken cancellationToken)
    {
        var literal = OperatingSystem.IsWindows() ? ":(top,literal,icase)" : ":(top,literal)";
        // Omitting a trailing slash lets Git consult absent indexed ancestor ignore files.
        var scope = string.Equals(directory, Path.TrimEndingDirectorySeparator(repositoryRoot), PipelineOutputRoot.PathComparison) ? ":/" :
            literal + Path.GetRelativePath(repositoryRoot, directory).Replace(Path.DirectorySeparatorChar, '/').TrimEnd('/');
        var index = await ReadIndexAsync(scope, cancellationToken).ConfigureAwait(false);
        var candidates = new HashSet<string>(index.Keys, PipelineOutputRoot.PathComparer);
        candidates.UnionWith(await ReadUntrackedPathsAsync(scope, cancellationToken).ConfigureAwait(false));
        var relative = candidates.Where(path => PipelineOutputRoot.IsWithinDirectory(directory, path))
            .Select(path => Path.GetRelativePath(directory, path));
        var matches = matcher.Match(directory, relative).Files;
        var entries = new List<SelectedFile>(matches.TryGetNonEnumeratedCount(out var count) ? count : 0);
        foreach (var match in matches)
        {
            cancellationToken.ThrowIfCancellationRequested();
            var file = new FileInfo(Path.GetFullPath(match.Path, directory));
            index.TryGetValue(file.FullName, out var indexed);
            if (indexed is { IsGitLink: true })
            {
                continue;
            }
            try
            {
                var attributes = File.GetAttributes(file.FullName);
                if (attributes.IsDirectory() && !attributes.IsJunctionOrSymlink())
                {
                    continue;
                }
            }
            catch (Exception exception) when (exception is FileNotFoundException or DirectoryNotFoundException)
            {
                continue;
            }
            entries.Add(new(file, indexed));
        }
        return entries;
    }
 
    private async Task<Dictionary<string, IndexEntry?>> ReadIndexAsync(string scope, CancellationToken cancellationToken)
    {
        var output = await RunGitAsync(["ls-files", "-t", "-v", "--stage", "--full-name", "-z", "--", scope], cancellationToken).ConfigureAwait(false);
        if (!TryGetRecordPayload(output.AsSpan(), out var records))
        {
            ThrowMalformed("index", "record framing", output.AsSpan());
        }
 
        var index = new Dictionary<string, IndexEntry?>(PipelineOutputRoot.PathComparer);
        if (!records.IsEmpty)
        {
            foreach (var range in records.Split('\0'))
            {
                var entry = IndexEntry.Parse(records[range]);
                var path = Path.GetFullPath(entry.Path, repositoryRoot);
                if (entry.IsMerged)
                {
                    index[path] = entry;
                }
                else
                {
                    // Unmerged paths remain members, but have no normal index hash to reuse.
                    index.TryAdd(path, null);
                }
            }
        }
        return index;
    }
 
    private async Task<IReadOnlyList<string>> ReadUntrackedPathsAsync(string scope, CancellationToken cancellationToken)
    {
        var output = await RunGitAsync(["ls-files", "--others", "--exclude-standard", "--full-name", "-z", "--", scope], cancellationToken).ConfigureAwait(false);
        if (!TryGetRecordPayload(output.AsSpan(), out var records))
        {
            ThrowMalformed("untracked paths", "record framing", output.AsSpan());
        }
 
        var paths = new List<string>();
        if (!records.IsEmpty)
        {
            foreach (var range in records.Split('\0'))
            {
                var path = records[range];
                if (!path.EndsWith("/", StringComparison.Ordinal))
                {
                    paths.Add(Path.GetFullPath(path.ToString(), repositoryRoot));
                }
            }
        }
        return paths;
    }
 
    /// <summary>
    /// Reads tracked working-tree changes and modes without interpreting HEAD or staged changes.
    /// </summary>
    internal async Task<IReadOnlyDictionary<string, StatusEntry>> ReadStatusAsync(CancellationToken cancellationToken)
    {
        var output = await RunGitAsync(["status", "--porcelain=v2", "-z", "--untracked-files=no", "--no-renames"], cancellationToken).ConfigureAwait(false);
        if (!TryGetRecordPayload(output.AsSpan(), out var records))
        {
            ThrowMalformed("status", "record framing", output.AsSpan());
        }
 
        var working = new Dictionary<string, StatusEntry>(PipelineOutputRoot.PathComparer);
        if (!records.IsEmpty)
        {
            foreach (var range in records.Split('\0'))
            {
                var entry = StatusEntry.Parse(records[range]);
                working[Path.GetFullPath(entry.Path, repositoryRoot)] = entry;
            }
        }
        return working;
    }
 
    /// <summary>
    /// Hashes a link's destination text without applying file attributes or filters.
    /// </summary>
    internal async Task<ReadOnlyMemory<byte>> HashLinkTargetAsync(string target, CancellationToken cancellationToken)
    {
        // Git stores a link's destination text. hash-object on a filename follows
        // the link, so hash the destination text through stdin without filters.
        // https://git-scm.com/docs/git-hash-object
        var output = await RunGitAsync(["hash-object", "--stdin"], cancellationToken, Encoding.UTF8.GetBytes(target)).ConfigureAwait(false);
        var id = output.AsSpan().TrimEnd("\r\n");
        if (!TryParseObjectId(id, out var hash))
        {
            ThrowMalformed("link object ID", "object ID", id);
        }
 
        return hash;
    }
 
    /// <summary>
    /// Hashes ordinary files in one batch using Git's configured attributes and filters.
    /// </summary>
    internal async Task<ReadOnlyMemory<byte>[]> HashFilesAsync(IEnumerable<FileInfo> files, CancellationToken cancellationToken)
    {
        // --stdin-paths reads one absolute UTF-8 filename per line, e.g. C:/repo/雪.txt\n.
        // Git opens the files and applies their attributes. One batch avoids a process per file.
        // https://git-scm.com/docs/git-hash-object
        var input = new ArrayBufferWriter<byte>();
        foreach (var file in files)
        {
            cancellationToken.ThrowIfCancellationRequested();
            var path = file.FullName.AsSpan();
            ThrowIfUnsupportedPath(path);
            var bytes = input.GetSpan(Encoding.UTF8.GetByteCount(path) + 1);
            var written = Encoding.UTF8.GetBytes(path, bytes);
            if (OperatingSystem.IsWindows())
            {
                // Git attribute lookup uses '/' separators; Unix backslashes are filename characters.
                // https://github.com/git/git/blob/v2.56.0/attr.c#L1165-L1184
                bytes[..written].Replace((byte)'\\', (byte)'/');
            }
            bytes[written] = (byte)'\n';
            input.Advance(written + 1);
        }
        var output = await RunGitAsync(["hash-object", "--stdin-paths"], cancellationToken, input.WrittenMemory).ConfigureAwait(false);
        return ReadObjectIds(output.AsSpan());
    }
 
    /// <summary>
    /// Rejects filenames that cannot be represented by the newline-delimited Git hashing input.
    /// </summary>
    internal static void ThrowIfUnsupportedPath(ReadOnlySpan<char> path)
    {
        if (path.IndexOfAny('\r', '\n') >= 0)
        {
            throw new DistributedApplicationException("Git pipeline output hashing does not support filenames containing CR or LF.");
        }
    }
 
    private static ReadOnlyMemory<byte>[] ReadObjectIds(ReadOnlySpan<char> output)
    {
        var ids = new List<ReadOnlyMemory<byte>>();
        foreach (var line in output.EnumerateLines())
        {
            if (!line.IsEmpty)
            {
                if (!TryParseObjectId(line, out var hash))
                {
                    ThrowMalformed("object IDs", "object ID", line);
                }
 
                ids.Add(hash);
            }
        }
        return ids.ToArray();
    }
 
    private static bool TryParseObjectId(ReadOnlySpan<char> id, out ReadOnlyMemory<byte> hash)
    {
        hash = default;
        // Full object IDs use 40 hexadecimal digits for SHA-1 or 64 for SHA-256.
        // https://github.com/git/git/blob/v2.56.0/Documentation/technical/hash-function-transition.adoc
        if (id.Length is not (40 or 64) || id.IndexOfAnyExcept(s_hexDigits) >= 0)
        {
            return false;
        }
 
        var bytes = new byte[id.Length];
        Encoding.ASCII.GetBytes(id, bytes);
        hash = bytes;
        return true;
    }
 
    private static bool IsMode(ReadOnlySpan<char> mode) => mode.Length == 6 && mode.IndexOfAnyExcept(s_octalDigits) < 0;
 
    /// <summary>
    /// Throws a malformed-output error with bounded Git diagnostics and optional additional data.
    /// </summary>
    [DoesNotReturn]
    internal static void ThrowMalformed(string kind, string field, ReadOnlySpan<char> value, IEnumerable<KeyValuePair<string, object?>>? additionalData = null)
    {
        var exception = new DistributedApplicationException($"Git returned malformed pipeline output {kind}.");
        exception.Data["GitRecordKind"] = kind;
        exception.Data["GitField"] = field;
        // A misbehaving filter can emit a large response instead of an object ID.
        const int MaxDiagnosticValueLength = 256;
        exception.Data["GitValue"] = value[..Math.Min(value.Length, MaxDiagnosticValueLength)].ToString();
        exception.Data["GitValueLength"] = value.Length;
        if (additionalData is not null)
        {
            foreach (var entry in additionalData)
            {
                exception.Data[entry.Key] = entry.Value;
            }
        }
 
        throw exception;
    }
 
    private static bool TryGetRecordPayload(ReadOnlySpan<char> output, out ReadOnlySpan<char> records)
    {
        records = default;
        if (output.IsEmpty)
        {
            return true;
        }
 
        // -z emits raw paths, including spaces/tabs/newlines, with one NUL after each record.
        if (output[^1] != '\0' || output[0] == '\0' || output.Contains("\0\0", StringComparison.Ordinal))
        {
            return false;
        }
 
        records = output[..^1];
        return true;
    }
 
    private async Task<string> RunGitAsync(string[] arguments, CancellationToken cancellationToken, ReadOnlyMemory<byte> input = default)
    {
        var result = await processRunner.RunAndCaptureTextAsync("git", ["--no-optional-locks", .. arguments], repositoryRoot,
            s_environmentVariables, input, cancellationToken).ConfigureAwait(false);
        if (result.ExitCode != 0)
        {
            throw new DistributedApplicationException($"Git could not capture pipeline output (exit code {result.ExitCode}): {result.StandardError.Trim()}");
        }
        return result.StandardOutput;
    }
 
    internal sealed record SelectedFile(FileInfo File, IndexEntry? Index);
 
    internal sealed record IndexEntry(string Mode, ReadOnlyMemory<byte> Hash, char Stage, char Tag, string Path)
    {
        internal bool IsReusable => Tag == 'H';
        // Git index modes: 100644 regular non-executable, 100755 regular executable, 160000 gitlink (submodule).
        // https://github.com/git/git/blob/v2.56.0/Documentation/gitformat-index.adoc
        internal bool IsRegularFile => Mode is "100644" or "100755";
        internal bool IsGitLink => Mode == "160000";
        internal bool IsMerged => Stage == '0';
 
        /// <summary>
        /// Parses an index entry while preserving its repository-relative path.
        /// </summary>
        internal static IndexEntry Parse(ReadOnlySpan<char> record)
        {
            // -t -v --stage: "H 100644 <oid> 0\tpath\0". h and S/s are index-ignore hints.
            // Stage 0 is the normal (merged) entry; stages 1/2/3 are base/ours/theirs.
            // https://git-scm.com/docs/git-ls-files
            var tab = record.IndexOf('\t');
            Span<Range> fields = stackalloc Range[4];
            var header = tab > 0 ? record[..tab] : [];
            if (header.Split(fields, ' ') != fields.Length || tab == record.Length - 1)
            {
                ThrowMalformed("index", "record", record);
            }
 
            var tag = header[fields[0]];
            var mode = header[fields[1]];
            var objectId = header[fields[2]];
            var stage = header[fields[3]];
            var path = record[(tab + 1)..];
            if (tag.Length != 1 || tag.IndexOfAnyExcept(s_indexTags) >= 0 || !IsMode(mode) || mode.SequenceEqual("000000") ||
                stage.Length != 1 || stage[0] is < '0' or > '3')
            {
                ThrowMalformed("index", "record", record);
            }
            if (!TryParseObjectId(objectId, out var hash))
            {
                ThrowMalformed("index", "object ID", objectId);
            }
 
            return new(mode.ToString(), hash, stage[0], tag[0], path.ToString());
        }
    }
 
    internal sealed record StatusEntry(char Y, string Mode, string Path)
    {
        // Git's zero mode means there is no working-tree entry at this indexed path.
        internal bool IsDeleted => Y == 'D' || Mode == "000000";
 
        /// <summary>
        /// Parses the working-tree status, mode, and path from an ordinary or unmerged status entry.
        /// </summary>
        internal static StatusEntry Parse(ReadOnlySpan<char> record)
        {
            // Ordinary: "1 XY sub mH mI mW hH hI path\0"; unmerged:
            // "u XY sub m1 m2 m3 mW h1 h2 h3 path\0". Paths can contain spaces/tabs.
            // Only Y, mW and path affect capture. Preserve framing without interpreting unused fields.
            // https://git-scm.com/docs/git-status#_porcelain_format_version_2
            var ordinary = record.StartsWith("1 ", StringComparison.Ordinal);
            if (!ordinary && !record.StartsWith("u ", StringComparison.Ordinal))
            {
                ThrowMalformed("status", "record type", record);
            }
 
            Span<Range> fields = stackalloc Range[ordinary ? 9 : 11];
            if (record.Split(fields, ' ') != fields.Length)
            {
                ThrowMalformed("status", "record", record);
            }
 
            var status = record[fields[1]];
            var workingTreeMode = record[fields[ordinary ? 5 : 6]];
            var path = record[fields[^1]];
            if (status.Length != 2 || status[1..].IndexOfAnyExcept(s_statusCodes) >= 0 || !IsMode(workingTreeMode) || path.IsEmpty)
            {
                ThrowMalformed("status", "record", record);
            }
 
            return new(status[1], workingTreeMode.ToString(), path.ToString());
        }
    }
}