diff --git a/src/CompletionPredictor.cs b/src/CompletionPredictor.cs index 4f5f7c2..da6a55a 100644 --- a/src/CompletionPredictor.cs +++ b/src/CompletionPredictor.cs @@ -1,3 +1,4 @@ +using System.Diagnostics.CodeAnalysis; using System.Management.Automation.Language; using System.Management.Automation.Runspaces; using System.Management.Automation.Subsystem.Prediction; @@ -10,6 +11,8 @@ public partial class CompletionPredictor : ICommandPredictor, IDisposable { private readonly Guid _guid; private readonly Runspace _runspace; + private readonly GitHandler _gitHandler; + private string? _cwd; private int _lock = 1; private static HashSet s_cmdList = new(StringComparer.OrdinalIgnoreCase) @@ -21,11 +24,13 @@ public partial class CompletionPredictor : ICommandPredictor, IDisposable "where", "Where-Object", "cd", + "git", }; internal CompletionPredictor(string guid) { _guid = new Guid(guid); + _gitHandler = new GitHandler(); _runspace = RunspaceFactory.CreateRunspace(InitialSessionState.CreateDefault()); _runspace.Name = nameof(CompletionPredictor); _runspace.Open(); @@ -47,25 +52,55 @@ public SuggestionPackage GetSuggestion(PredictionClient client, PredictionContex { // When it ends at a white space, it would likely trigger argument completion which in most cases would be file-operation // intensive. That's not only slow but also undesirable in most cases, so we skip it. - // But, there are exceptions for 'ForEach-Object' and 'Where-Object', where completion on member names is quite useful. - Ast lastAst = relatedAsts[^1]; - var cmdName = (lastAst.Parent as CommandAst)?.CommandElements[0] as StringConstantExpressionAst; - if (cmdName is null || !s_cmdList.Contains(cmdName.Value) || !object.ReferenceEquals(lastAst, cmdName)) + // But, there are exceptions for some commands, where completion on member names is quite useful. + if (!IsCommandAstWithLiteralName(context, out var cmdAst, out var nameAst) + || !s_cmdList.TryGetValue(nameAst.Value, out string? cmd)) { - // So we stop processing unless the cursor is right after 'ForEach-Object' or 'Where-Object'. + // Stop processing if the cursor is not at the end of an allowed command. return default; } - } - if (tokenAtCursor is not null && tokenAtCursor.TokenFlags.HasFlag(TokenFlags.CommandName)) + if (cmd is "git") + { + // Process 'git' command. + return _gitHandler.GetGitResult(cmdAst, _cwd, context, cancellationToken); + } + + if (cmdAst.CommandElements.Count != 1) + { + // For commands other than 'git', we only do argument completion if the cursor is right after the command name. + return default; + } + } + else { - // When it's a command, it would likely take too much time because the command discovery is usually expensive, so we skip it. - return default; + if (tokenAtCursor.TokenFlags.HasFlag(TokenFlags.CommandName)) + { + // When it's a command, it would likely take too much time because the command discovery is usually expensive, so we skip it. + return default; + } + + if (IsCommandAstWithLiteralName(context, out var cmdAst, out var nameAst) + && string.Equals(nameAst.Value, "git", StringComparison.OrdinalIgnoreCase)) + { + return _gitHandler.GetGitResult(cmdAst, _cwd, context, cancellationToken); + } } return GetFromTabCompletion(context, cancellationToken); } + private bool IsCommandAstWithLiteralName( + PredictionContext context, + [NotNullWhen(true)] out CommandAst? cmdAst, + [NotNullWhen(true)] out StringConstantExpressionAst? nameAst) + { + Ast lastAst = context.RelatedAsts[^1]; + cmdAst = lastAst.Parent as CommandAst; + nameAst = cmdAst?.CommandElements[0] as StringConstantExpressionAst; + return nameAst is not null; + } + private SuggestionPackage GetFromTabCompletion(PredictionContext context, CancellationToken cancellationToken) { // Call into PowerShell tab completion to get completion results. @@ -155,11 +190,18 @@ public void Dispose() #region "Unused interface members because this predictor doesn't process feedback" - public bool CanAcceptFeedback(PredictionClient client, PredictorFeedbackKind feedback) => false; + public bool CanAcceptFeedback(PredictionClient client, PredictorFeedbackKind feedback) + { + return feedback == PredictorFeedbackKind.CommandLineAccepted ? true : false; + } + public void OnSuggestionDisplayed(PredictionClient client, uint session, int countOrIndex) { } public void OnSuggestionAccepted(PredictionClient client, uint session, string acceptedSuggestion) { } - public void OnCommandLineAccepted(PredictionClient client, IReadOnlyList history) { } public void OnCommandLineExecuted(PredictionClient client, string commandLine, bool success) { } + public void OnCommandLineAccepted(PredictionClient client, IReadOnlyList history) + { + _gitHandler.SignalCheckForRepoUpdate(); + } #endregion; } diff --git a/src/CompletionPredictorStateSync.cs b/src/CompletionPredictorStateSync.cs index e5885b4..6a5d4d3 100644 --- a/src/CompletionPredictorStateSync.cs +++ b/src/CompletionPredictorStateSync.cs @@ -149,6 +149,7 @@ private void SyncCurrentPath(Runspace source) { PathInfo currentPath = source.SessionStateProxy.Path.CurrentLocation; _runspace.SessionStateProxy.Path.SetLocation(currentPath.Path); + _cwd = source.SessionStateProxy.Path.CurrentFileSystemLocation.ProviderPath; } private void SyncVariables(Runspace source) diff --git a/src/CustomHandlers/GitHandler.cs b/src/CustomHandlers/GitHandler.cs new file mode 100644 index 0000000..99facf5 --- /dev/null +++ b/src/CustomHandlers/GitHandler.cs @@ -0,0 +1,144 @@ +using System.Collections.Concurrent; +using System.Collections.ObjectModel; +using System.Diagnostics.CodeAnalysis; +using System.Management.Automation.Language; +using System.Management.Automation.Subsystem.Prediction; + +namespace Microsoft.PowerShell.Predictor; + +internal partial class GitHandler +{ + private readonly ConcurrentDictionary _repos; + private readonly Dictionary _gitCmds; + + internal GitHandler() + { + _repos = new(StringComparer.Ordinal); + _gitCmds = new(StringComparer.Ordinal) + { + { "merge", new Merge() }, + { "branch", new Branch() }, + { "checkout", new Checkout() }, + { "push", new Push() }, + }; + } + + internal void SignalCheckForRepoUpdate() + { + foreach (var repoInfo in _repos.Values) + { + repoInfo.NeedCheckForUpdate(); + } + } + + internal SuggestionPackage GetGitResult(CommandAst gitAst, string? cwd, PredictionContext context, CancellationToken token) + { + var elements = gitAst.CommandElements; + if (cwd is null || elements.Count is 1 || !TryConvertToText(elements, out List? textElements)) + { + return default; + } + + RepoInfo? repoInfo = GetRepoInfo(cwd); + if (repoInfo is null || token.IsCancellationRequested) + { + return default; + } + + string gitCmd = textElements[1]; + string? textAtCursor = context.TokenAtCursor?.Text; + bool cursorAtGitCmd = textElements.Count is 2 && textAtCursor is not null; + + if (!_gitCmds.TryGetValue(gitCmd, out GitNode? node)) + { + if (cursorAtGitCmd) + { + foreach (var entry in _gitCmds) + { + if (entry.Key.StartsWith(textAtCursor!)) + { + node = entry.Value; + break; + } + } + } + } + + if (node is not null) + { + return node.Predict(textElements, textAtCursor, context.InputAst.Extent.Text, repoInfo, cursorAtGitCmd); + } + + return default; + } + + private bool TryConvertToText( + ReadOnlyCollection elements, + [NotNullWhen(true)] out List? textElements) + { + textElements = new(elements.Count); + foreach (var e in elements) + { + switch (e) + { + case StringConstantExpressionAst str: + textElements.Add(str.Value); + break; + case CommandParameterAst param: + textElements.Add(param.Extent.Text); + break; + default: + textElements = null; + return false; + } + } + + return true; + } + + private RepoInfo? GetRepoInfo(string cwd) + { + if (_repos.TryGetValue(cwd, out RepoInfo? repoInfo)) + { + return repoInfo; + } + + foreach (var entry in _repos) + { + string root = entry.Key; + if (cwd.StartsWith(root) && cwd[root.Length] == Path.DirectorySeparatorChar) + { + repoInfo = entry.Value; + break; + } + } + + if (repoInfo is null) + { + string? repoRoot = FindRepoRoot(cwd); + if (repoRoot is not null) + { + repoInfo = _repos.GetOrAdd(repoRoot, new RepoInfo(repoRoot)); + } + } + + return repoInfo; + } + + private string? FindRepoRoot(string currentLocation) + { + string? root = currentLocation; + while (root is not null) + { + string gitDir = Path.Join(root, ".git", "refs"); + if (Directory.Exists(gitDir)) + { + return root; + } + + root = Path.GetDirectoryName(root); + } + + return null; + } +} diff --git a/src/CustomHandlers/GitNode.cs b/src/CustomHandlers/GitNode.cs new file mode 100644 index 0000000..f88cdf7 --- /dev/null +++ b/src/CustomHandlers/GitNode.cs @@ -0,0 +1,515 @@ +using System.Management.Automation.Subsystem.Prediction; + +namespace Microsoft.PowerShell.Predictor; + +internal abstract class GitNode +{ + internal readonly string Name; + + protected GitNode(string name) + { + Name = name; + } + + internal abstract SuggestionPackage Predict(List textElements, string? textAtCursor, string origInput, RepoInfo repoInfo, bool cursorAtGitCmd); +} + +internal sealed class Merge : GitNode +{ + internal Merge() : base("merge") { } + + internal override SuggestionPackage Predict( + List textElements, + string? textAtCursor, + string origInput, + RepoInfo repoInfo, + bool cursorAtGitCmd) + { + if (textAtCursor is not null && textAtCursor.StartsWith('-')) + { + // We don't predict flag/option today, but may support it in future. + return default; + } + + bool predictArg = true; + for (int i = 2; i < textElements.Count; i++) + { + if (textElements[i] is "--continue" or "--abort" or "--quit") + { + predictArg = false; + break; + } + } + + if (predictArg) + { + string filter = (cursorAtGitCmd ? null : textAtCursor) ?? string.Empty; + List? args = PredictArgument(filter, repoInfo); + if (args is not null) + { + List list = new(args.Count); + foreach (string arg in args) + { + if (textAtCursor is null) + { + list.Add(new PredictiveSuggestion($"{origInput}{arg}")); + } + else if (cursorAtGitCmd) + { + var remainingPortionInCmd = Name.AsSpan(textAtCursor.Length); + list.Add(new PredictiveSuggestion($"{origInput}{remainingPortionInCmd} {arg}")); + } + else + { + var remainingPortionInArg = arg.AsSpan(textAtCursor.Length); + list.Add(new PredictiveSuggestion($"{origInput}{remainingPortionInArg}")); + } + } + + return new SuggestionPackage(list); + } + } + + return default; + } + + private List? PredictArgument(string filter, RepoInfo repoInfo) + { + List? ret = null; + string activeBranch = repoInfo.ActiveBranch; + + if (filter.Length is 0 || !filter.Contains('/')) + { + foreach (RemoteInfo remote in repoInfo.Remotes) + { + string remoteName = remote.Name; + if (remote.Branches is null || !remoteName.StartsWith(filter, StringComparison.Ordinal)) + { + continue; + } + + foreach (string branch in remote.Branches) + { + if (branch != activeBranch) + { + continue; + } + + ret ??= new List(); + string candidate = $"{remoteName}/{branch}"; + if (remoteName == "upstream") + { + ret.Insert(index: 0, candidate); + } + else + { + ret.Add(candidate); + } + + break; + } + } + + foreach (string localBranch in repoInfo.Branches) + { + if (localBranch != activeBranch && localBranch.StartsWith(filter, StringComparison.Ordinal)) + { + ret ??= new List(); + ret.Add(localBranch); + } + } + } + else + { + int slashIndex = filter.IndexOf('/'); + if (slashIndex > 0) + { + var remoteName = filter.AsSpan(0, slashIndex); + var branchName = filter.AsSpan(slashIndex + 1); + + foreach (RemoteInfo remote in repoInfo.Remotes) + { + if (remote.Branches is null || !MemoryExtensions.Equals(remote.Name, remoteName, StringComparison.Ordinal)) + { + continue; + } + + foreach (string branch in remote.Branches) + { + if (branch.AsSpan().StartsWith(branchName, StringComparison.Ordinal) && branch.Length > branchName.Length) + { + ret ??= new List(); + string candidate = $"{remoteName}/{branch}"; + if (branch == activeBranch) + { + ret.Insert(0, candidate); + } + else + { + ret.Add(candidate); + } + } + } + + break; + } + } + + if (ret is null) + { + foreach (string localBranch in repoInfo.Branches) + { + if (localBranch == activeBranch) + { + continue; + } + + if (localBranch.StartsWith(filter, StringComparison.Ordinal) && localBranch.Length > filter.Length) + { + ret ??= new List(); + ret.Add(localBranch); + } + } + } + } + + return ret; + } +} + +internal sealed class Branch : GitNode +{ + internal Branch() : base("branch") { } + + internal override SuggestionPackage Predict( + List textElements, + string? textAtCursor, + string origInput, + RepoInfo repoInfo, + bool cursorAtGitCmd) + { + ReadOnlySpan autoFill = null; + bool predictArg = false; + + if (cursorAtGitCmd) + { + return default; + } + + if (textElements.Count is 2 && textAtCursor is null) + { + autoFill = "-D ".AsSpan(); + predictArg = true; + } + + if (textAtCursor is not null && textAtCursor.StartsWith('-')) + { + if (textAtCursor is "-" or "-d" or "-D") + { + autoFill = "-D ".AsSpan(textAtCursor.Length); + predictArg = true; + textAtCursor = null; + } + else + { + // We don't predict flag/option today, but may support it in future. + return default; + } + } + + if (!predictArg) + { + for (int i = 2; i < textElements.Count; i++) + { + if (textElements[i] is "-d" or "-D") + { + predictArg = true; + break; + } + } + } + + if (predictArg) + { + List? args = PredictArgument(textAtCursor ?? string.Empty, repoInfo); + if (args is not null) + { + List list = new(args.Count); + foreach (string arg in args) + { + if (textAtCursor is null) + { + list.Add(new PredictiveSuggestion($"{origInput}{autoFill}{arg}")); + } + else + { + var remainingPortionInArg = arg.AsSpan(textAtCursor.Length); + list.Add(new PredictiveSuggestion($"{origInput}{remainingPortionInArg}")); + } + } + + return new SuggestionPackage(list); + } + } + + return default; + } + + private List? PredictArgument(string filter, RepoInfo repoInfo) + { + List? ret = null; + List? originBranches = null; + + foreach (var remote in repoInfo.Remotes) + { + if (remote.Name is "origin") + { + originBranches = remote.Branches; + break; + } + } + + if (originBranches is not null) + { + // The 'origin' remote exists, so do a smart check to find those local branches + // that are not available in the 'origin' remote branches. + HashSet localBranches = new(repoInfo.Branches); + localBranches.ExceptWith(originBranches); + + if (localBranches.Count > 0) + { + foreach (string branch in localBranches) + { + if (branch.StartsWith(filter, StringComparison.Ordinal) && + branch != repoInfo.ActiveBranch) + { + ret ??= new List(); + ret.Add(branch); + } + } + } + } + else + { + // No 'origin' remote, so just list the local branches, except for the default branch + // and the current active branch. + foreach (string branch in repoInfo.Branches) + { + if (branch.StartsWith(filter, StringComparison.Ordinal) && + branch != repoInfo.ActiveBranch && + branch != repoInfo.DefaultBranch) + { + ret ??= new List(); + ret.Add(branch); + } + } + } + + return ret; + } +} + +internal sealed class Checkout : GitNode +{ + internal Checkout() : base("checkout") { } + + internal override SuggestionPackage Predict( + List textElements, + string? textAtCursor, + string origInput, + RepoInfo repoInfo, + bool cursorAtGitCmd) + { + if (textAtCursor is not null && textAtCursor.StartsWith('-')) + { + // We don't predict flag/option today, but may support it in future. + return default; + } + + int argCount = 0; + bool predictArg = true; + bool hasDashB = false; + + for (int i = 2; i < textElements.Count; i++) + { + if (textElements[i] is "-b" or "-B") + { + hasDashB = true; + continue; + } + + if (hasDashB && !textElements[i].StartsWith('-')) + { + argCount += 1; + } + } + + if (hasDashB) + { + predictArg = (argCount is 1 && textAtCursor is null) + || (argCount is 2 && textAtCursor is not null); + } + + if (predictArg) + { + string filter = (cursorAtGitCmd ? null : textAtCursor) ?? string.Empty; + List? args = PredictArgument(filter, repoInfo, hasDashB ? false : true); + if (args is not null) + { + List list = new(args.Count); + foreach (string arg in args) + { + if (textAtCursor is null) + { + list.Add(new PredictiveSuggestion($"{origInput}{arg}")); + } + else if (cursorAtGitCmd) + { + var remainingPortionInCmd = Name.AsSpan(textAtCursor!.Length); + list.Add(new PredictiveSuggestion($"{origInput}{remainingPortionInCmd} {arg}")); + } + else + { + var remainingPortionInArg = arg.AsSpan(textAtCursor.Length); + list.Add(new PredictiveSuggestion($"{origInput}{remainingPortionInArg}")); + } + } + + return new SuggestionPackage(list); + } + } + + return default; + } + + private List? PredictArgument(string filter, RepoInfo repoInfo, bool excludeActiveBranch) + { + List? ret = null; + + foreach (string localBranch in repoInfo.Branches) + { + if (excludeActiveBranch && localBranch == repoInfo.ActiveBranch) + { + continue; + } + + if (localBranch.StartsWith(filter, StringComparison.Ordinal) && + localBranch.Length > filter.Length) + { + ret ??= new List(); + ret.Add(localBranch); + } + } + + return ret; + } +} + +internal sealed class Push : GitNode +{ + internal Push() : base("push") { } + + internal override SuggestionPackage Predict( + List textElements, + string? textAtCursor, + string origInput, + RepoInfo repoInfo, + bool cursorAtGitCmd) + { + ReadOnlySpan autoFill = null; + bool hasAutoFill = false; + + if (cursorAtGitCmd) + { + hasAutoFill = true; + autoFill = Name.AsSpan(textAtCursor!.Length); + textAtCursor = null; + } + + if (textAtCursor is not null && textAtCursor.StartsWith('-')) + { + const string forceWithLease = "--force-with-lease "; + if (forceWithLease.StartsWith(textAtCursor, StringComparison.Ordinal)) + { + hasAutoFill = true; + autoFill = forceWithLease.AsSpan(textAtCursor.Length); + textAtCursor = null; + } + else + { + // We don't predict flag/option today, but may support it in future. + return default; + } + } + + int argCount = 0; + for (int i = 2; i < textElements.Count; i++) + { + if (!textElements[i].StartsWith('-')) + { + argCount += 1; + } + } + + int pos = -1; + if ((argCount is 0 && textAtCursor is null) + || (argCount is 1 && textAtCursor is not null)) + { + pos = 0; + } + else if ((argCount is 1 && textAtCursor is null) + || (argCount is 2 && textAtCursor is not null)) + { + pos = 1; + } + + string filter = textAtCursor ?? string.Empty; + string activeBranch = repoInfo.ActiveBranch; + List? list = null; + + if (pos is 0) + { + foreach (RemoteInfo remote in repoInfo.Remotes) + { + string remoteName = remote.Name; + if (!remoteName.StartsWith(filter, StringComparison.Ordinal)) + { + continue; + } + + string candidate; + list ??= new List(); + + if (textAtCursor is null) + { + candidate = hasAutoFill + ? $"{origInput}{autoFill} {remoteName} {activeBranch}" + : $"{origInput}{remoteName} {activeBranch}"; + } + else + { + candidate = $"{origInput}{remoteName.AsSpan(textAtCursor.Length)} {activeBranch}"; + } + + if (remoteName is "origin") + { + list.Insert(0, new PredictiveSuggestion(candidate)); + } + else + { + list.Add(new PredictiveSuggestion(candidate)); + } + } + } + else if (pos is 2) + { + if (textAtCursor is null || (activeBranch.StartsWith(textAtCursor) && activeBranch.Length > textAtCursor.Length)) + { + list ??= new List(); + list.Add(new PredictiveSuggestion($"{origInput}{activeBranch}")); + } + } + + return list is null ? default : new SuggestionPackage(list); + } +} diff --git a/src/CustomHandlers/GitRepoInfo.cs b/src/CustomHandlers/GitRepoInfo.cs new file mode 100644 index 0000000..ace17c5 --- /dev/null +++ b/src/CustomHandlers/GitRepoInfo.cs @@ -0,0 +1,267 @@ +using System.Runtime.InteropServices; + +namespace Microsoft.PowerShell.Predictor; + +internal class RepoInfo +{ + private readonly object _syncObj; + private readonly string _root; + private readonly string _git; + private readonly string _head; + private readonly string _ref_remotes; + private readonly string _ref_heads; + + private bool _checkForUpdate; + private string? _defaultBranch; + private string? _activeBranch; + private List? _branches; + private List? _remotes; + private DateTime? _ref_remote_LastWrittenTimeUtc; + private DateTime? _ref_heads_LastWrittenTimeUtc; + private DateTime? _head_LastWrittenTimeUtc; + + internal RepoInfo(string root) + { + _syncObj = new(); + _checkForUpdate = true; + _root = root; + _git = Path.Join(root, ".git"); + _head = Path.Join(_git, "HEAD"); + _ref_heads = Path.Join(_git, "refs", "heads"); + _ref_remotes = Path.Join(_git, "refs", "remotes"); + } + + internal string RepoRoot => _root; + + internal string DefaultBranch + { + get + { + if (_defaultBranch is null) + { + Refresh(); + } + return _defaultBranch!; + } + } + + internal string ActiveBranch + { + get + { + Refresh(); + return _activeBranch!; + } + } + + internal List Branches + { + get + { + Refresh(); + return _branches!; + } + } + + internal List Remotes + { + get + { + Refresh(); + return _remotes!; + } + } + + internal void NeedCheckForUpdate() + { + _checkForUpdate = true; + foreach (var remote in _remotes!) + { + remote.NeedCheckForUpdate(); + } + } + + private void Refresh() + { + if (_checkForUpdate) + { + lock(_syncObj) + { + if (_checkForUpdate) + { + if (_head_LastWrittenTimeUtc == null || File.GetLastWriteTimeUtc(_head) > _head_LastWrittenTimeUtc) + { + (_activeBranch, _head_LastWrittenTimeUtc) = GetActiveBranch(); + } + + if (_ref_heads_LastWrittenTimeUtc == null || Directory.GetLastWriteTimeUtc(_ref_heads) > _ref_heads_LastWrittenTimeUtc) + { + (_branches, _ref_heads_LastWrittenTimeUtc) = GetBranches(); + } + + if (_ref_remote_LastWrittenTimeUtc == null || Directory.GetLastWriteTimeUtc(_ref_remotes) > _ref_remote_LastWrittenTimeUtc) + { + (_remotes, _ref_remote_LastWrittenTimeUtc) = GetRemotes(); + } + + if (_defaultBranch is null) + { + bool hasMaster = false, hasMain = false; + foreach (var branch in _branches!) + { + if (branch == "master") + { + hasMaster = true; + break; + } + + if (branch == "main") + { + hasMain = true; + } + } + + _defaultBranch = hasMaster ? "master" : hasMain ? "main" : string.Empty; + } + + _checkForUpdate = false; + } + } + } + } + + private (string, DateTime) GetActiveBranch() + { + var head = new FileInfo(_head); + using var reader = head.OpenText(); + string content = reader.ReadLine()!; + return (content.Substring("ref: refs/heads/".Length), head.LastWriteTimeUtc); + } + + private (List, DateTime) GetBranches() + { + var ret = new List(); + var dirInfo = new DirectoryInfo(_ref_heads); + + if (dirInfo.Exists) + { + RemoteInfo.ReadBranches(dirInfo, ret); + return (ret, dirInfo.LastWriteTimeUtc); + } + + return (ret, DateTime.UtcNow); + } + + private (List, DateTime) GetRemotes() + { + var ret = new List(); + var dirInfo = new DirectoryInfo(_ref_remotes); + + if (dirInfo.Exists) + { + foreach (DirectoryInfo dir in dirInfo.EnumerateDirectories()) + { + ret.Add(new RemoteInfo(dir.Name, dir.FullName)); + } + + return (ret, dirInfo.LastWriteTimeUtc); + } + + return (ret, DateTime.UtcNow); + } +} + +internal class RemoteInfo +{ + private static readonly bool s_isWindows = RuntimeInformation.IsOSPlatform(OSPlatform.Windows); + private static readonly EnumerationOptions s_enumOption = new() { RecurseSubdirectories = true, IgnoreInaccessible = true, }; + + private readonly object _syncObj; + private readonly string _path; + + private bool _checkForUpdate; + private List? _branches; + private DateTime? _lastWrittenTimeUtc; + + internal readonly string Name; + + internal RemoteInfo(string name, string path) + { + _syncObj = new(); + _path = path; + + Name = name; + } + + internal List? Branches + { + get + { + Refresh(); + return _branches; + } + } + + internal void NeedCheckForUpdate() + { + _checkForUpdate = true; + } + + internal static void ReadBranches(DirectoryInfo dirInfo, List branches) + { + foreach (FileInfo file in dirInfo.EnumerateFiles("*", s_enumOption)) + { + string name = Path.GetRelativePath(dirInfo.FullName, file.FullName); + if (name == "HEAD") + { + using var reader = file.OpenText(); + string? content = reader.ReadLine(); + if (string.IsNullOrEmpty(content)) + { + continue; + } + + name = content.Substring("ref: refs/remotes/".Length + dirInfo.Name.Length + 1); + } + + branches.Add(s_isWindows ? name.Replace('\\', '/') : name); + } + } + + private void Refresh() + { + if (ShouldUpdate()) + { + lock(_syncObj) + { + if (ShouldUpdate()) + { + var dirInfo = new DirectoryInfo(_path); + var option = new EnumerationOptions() + { + RecurseSubdirectories = true, + IgnoreInaccessible = true, + }; + + var branches = new List(); + ReadBranches(dirInfo, branches); + + // Reference assignment is an atomic operation. + _branches = branches; + _checkForUpdate = false; + _lastWrittenTimeUtc = dirInfo.LastWriteTimeUtc; + } + } + } + } + + private bool ShouldUpdate() + { + if (_lastWrittenTimeUtc is null) + { + return true; + } + + return _checkForUpdate && Directory.GetLastWriteTimeUtc(_path) > _lastWrittenTimeUtc; + } +}