Editor/git/Reflog.cs
using System;
using System.Collections.Generic;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using Sandbox.git.models;
namespace Sandbox.git;
/// <summary>
/// Reflog-based operations: recent branches and branch checkouts.
/// </summary>
public static class Reflog {
const string OperationGetRecentBranches = "getRecentBranches";
const string OperationGetBranchCheckouts = "getBranchCheckouts";
static readonly IReadOnlySet<int> SuccessExitCodesReflog = new HashSet<int> { 0, 128 };
// .*? (renamed|checkout)(?:: moving from|\s*) (?:refs/heads/|\s*)(.*?) to (?:refs/heads/|\s*)(.*?)$
static readonly Regex RecentBranchesRe = new Regex(
@".*? (renamed|checkout)(?:: moving from|\s*) (?:refs/heads/|\s*)(.*?) to (?:refs/heads/|\s*)(.*?)$",
RegexOptions.IgnoreCase
);
// ^[a-z0-9]{40}\sHEAD@{(.*)}\scheckout: moving from\s.*\sto\s(.*)$
static readonly Regex BranchCheckoutLineRe = new Regex(
@"^[a-z0-9]{40}\sHEAD@{(.*)}\scheckout: moving from\s.*\sto\s(.*)$"
);
static readonly Regex NoCommitsOnBranchRe = new Regex(
@"fatal: your current branch '.*' does not have any commits yet"
);
/// <summary>
/// Gets the <paramref name="limit"/> most recently checked out branches.
/// Uses git log -g to avoid unbounded output on large reflogs.
/// </summary>
public static async Task<IReadOnlyList<string>> GetRecentBranchesAsync(
Repository repository,
int limit
) {
if ( repository == null )
return Array.Empty<string>();
var result = await Core.GitAsync(
GetRecentBranchesArgs(),
repository.Path,
OperationGetRecentBranches,
SuccessExitCodesReflog
).ConfigureAwait(false);
if ( result.ExitCode == 128 )
return Array.Empty<string>();
var names = new List<string>();
var excludedNames = new HashSet<string>();
foreach ( var line in result.Stdout.Split('\n') ) {
var match = RecentBranchesRe.Match(line);
if ( !match.Success || match.Groups.Count < 4 )
continue;
var operationType = match.Groups[1].Value;
var excludeBranchName = match.Groups[2].Value;
var branchName = match.Groups[3].Value;
if ( string.Equals(operationType, "renamed", StringComparison.OrdinalIgnoreCase) )
excludedNames.Add(excludeBranchName);
if ( !excludedNames.Contains(branchName) && !names.Contains(branchName) )
names.Add(branchName);
if ( names.Count >= limit )
break;
}
return names;
}
/// <summary>
/// Gets the distinct list of branches that have been checked out on or after <paramref name="afterDate"/>.
/// Returns a map of branch name to (first) checkout date.
/// </summary>
public static async Task<IReadOnlyDictionary<string, DateTime>> GetBranchCheckoutsAsync(
Repository repository,
DateTime afterDate
) {
if ( repository == null )
return new Dictionary<string, DateTime>();
var result = await Core.GitAsync(
GetBranchCheckoutsArgs(afterDate),
repository.Path,
OperationGetBranchCheckouts,
SuccessExitCodesReflog
).ConfigureAwait(false);
var checkouts = new Dictionary<string, DateTime>();
if ( result.ExitCode == 128 && NoCommitsOnBranchRe.IsMatch(result.Stderr) )
return checkouts;
if ( result.ExitCode != 0 )
return checkouts;
foreach ( var line in result.Stdout.Split('\n') ) {
var match = BranchCheckoutLineRe.Match(line);
if ( !match.Success || match.Groups.Count < 3 )
continue;
var timestampStr = match.Groups[1].Value;
var branchName = match.Groups[2].Value;
if ( checkouts.ContainsKey(branchName) )
continue;
if ( DateTime.TryParse(timestampStr, null, System.Globalization.DateTimeStyles.RoundtripKind, out var dt) )
checkouts[branchName] = dt;
}
return checkouts;
}
/// <summary>Builds git arguments for get recent branches (log -g). Exposed for testing.</summary>
public static string[] GetRecentBranchesArgs() {
return new[] { "log", "-g", "--no-abbrev-commit", "--pretty=oneline", "HEAD", "-n", "2500", "--" };
}
/// <summary>Builds git arguments for get branch checkouts (reflog). Exposed for testing.</summary>
public static string[] GetBranchCheckoutsArgs(DateTime afterDate) {
return new[] {
"reflog", "--date=iso", $"--after=\"{afterDate:O}\"", "--pretty=%H %gd %gs",
"--grep-reflog=checkout: moving from .* to .*$", "--"
};
}
}