228 lines
9.8 KiB
C#
228 lines
9.8 KiB
C#
using System;
|
|
using System.ComponentModel;
|
|
using System.Diagnostics;
|
|
using System.IO;
|
|
using System.Text;
|
|
|
|
namespace RMuseum.Utils.PublicDataExport
|
|
{
|
|
/// <summary>
|
|
/// Options for publishing the public export tree to a git remote.
|
|
/// Bind this from the "PublicDataExport" (or "TajikPublicDataExport") configuration section.
|
|
/// </summary>
|
|
public class GitRepoPublisherOptions
|
|
{
|
|
/// <summary>
|
|
/// local working copy path (e.g. C:\ganjoor-public-data or /var/ganjoor/public-data)
|
|
/// </summary>
|
|
public string LocalWorkingCopyPath { get; set; }
|
|
|
|
/// <summary>
|
|
/// remote URL, e.g. https://github.com/ganjoor/ganjoor-data.git
|
|
/// </summary>
|
|
public string RemoteUrl { get; set; }
|
|
|
|
public string Branch { get; set; } = "main";
|
|
|
|
public string CommitAuthorName { get; set; } = "Ganjoor Export Bot";
|
|
|
|
public string CommitAuthorEmail { get; set; } = "bot@ganjoor.net";
|
|
|
|
/// <summary>
|
|
/// if false, everything is written and committed locally but never pushed —
|
|
/// useful for a first dry run before wiring up real credentials
|
|
/// </summary>
|
|
public bool PushEnabled { get; set; }
|
|
|
|
/// <summary>
|
|
/// git username for the push (for GitHub, a PAT is used as both username-independent
|
|
/// and as the token below — GitHub only checks the token)
|
|
/// </summary>
|
|
public string GitUserName { get; set; }
|
|
|
|
/// <summary>
|
|
/// personal access token / app token with push rights to RemoteUrl. Keep this in
|
|
/// user-secrets / environment variables, never committed to appsettings.json.
|
|
/// </summary>
|
|
public string GitToken { get; set; }
|
|
}
|
|
|
|
/// <summary>
|
|
/// Thin wrapper around the native <c>git</c> CLI (not LibGit2Sharp) for the export job: sync a
|
|
/// local working copy, stage, commit, push.
|
|
///
|
|
/// This shells out to git.exe deliberately rather than using LibGit2Sharp's own transport.
|
|
/// LibGit2Sharp's built-in HTTP push turned out to be unreliable at this job's actual scale
|
|
/// (hundreds of thousands of small files, a correspondingly large push) — it repeatedly failed
|
|
/// mid-push with "error receiving data from socket: An existing connection was forcibly closed
|
|
/// by the remote host", a transport-level failure. The native git CLI (what every other git
|
|
/// client actually uses) handles large pushes far more robustly, so this class runs it as a
|
|
/// subprocess instead. Requires Git for Windows (or any git install) to be on PATH — the same
|
|
/// requirement as running `git` from a normal command prompt.
|
|
///
|
|
/// The auth token is passed only via a one-off `-c http.extraHeader=...` argument on whichever
|
|
/// single invocation needs it (clone/fetch/push) — it's never written into .git/config, so it
|
|
/// never touches disk.
|
|
/// </summary>
|
|
public class GitRepoPublisher
|
|
{
|
|
private readonly GitRepoPublisherOptions _options;
|
|
|
|
public GitRepoPublisher(GitRepoPublisherOptions options)
|
|
{
|
|
_options = options;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Ensures the local working copy exists and is up to date with the remote before the
|
|
/// export job starts writing files into it. Clones on first run, fetches + hard-resets
|
|
/// to the remote branch on subsequent runs (this working copy is bot-owned; nobody should
|
|
/// be hand-editing it, so a hard reset is safe and keeps the job idempotent).
|
|
/// </summary>
|
|
public void EnsureWorkingCopyUpToDate()
|
|
{
|
|
if (!Directory.Exists(_options.LocalWorkingCopyPath) ||
|
|
!Directory.Exists(Path.Combine(_options.LocalWorkingCopyPath, ".git")))
|
|
{
|
|
Directory.CreateDirectory(_options.LocalWorkingCopyPath);
|
|
RunGit(_options.LocalWorkingCopyPath, $"{BuildAuthArgs()}clone \"{_options.RemoteUrl}\" .");
|
|
return;
|
|
}
|
|
|
|
EnsureRemoteConfigured();
|
|
|
|
RunGit(_options.LocalWorkingCopyPath, $"{BuildAuthArgs()}fetch origin {_options.Branch}");
|
|
RunGit(_options.LocalWorkingCopyPath, $"reset --hard origin/{_options.Branch}");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Stages every change under the working copy and, if anything actually changed, commits
|
|
/// and (if enabled) pushes. Returns the number of files touched (0 means nothing changed
|
|
/// since last run — a normal, expected outcome on most nightly runs).
|
|
/// </summary>
|
|
public int CommitAndPush(string commitMessage)
|
|
{
|
|
RunGit(_options.LocalWorkingCopyPath, "add -A");
|
|
|
|
// "diff --cached --quiet" exits 1 if anything is staged, 0 if not — used purely for
|
|
// the exit code here, never prints anything either way
|
|
var diffResult = RunGitAllowNonZero(_options.LocalWorkingCopyPath, "diff --cached --quiet");
|
|
if (diffResult.ExitCode == 0)
|
|
{
|
|
return 0;
|
|
}
|
|
|
|
string statusOutput = RunGit(_options.LocalWorkingCopyPath, "status --porcelain").StandardOutput;
|
|
int changedCount = statusOutput.Split('\n', StringSplitOptions.RemoveEmptyEntries).Length;
|
|
|
|
string escapedMessage = commitMessage.Replace("\"", "\\\"");
|
|
RunGit(_options.LocalWorkingCopyPath,
|
|
$"-c user.name=\"{_options.CommitAuthorName}\" -c user.email=\"{_options.CommitAuthorEmail}\" commit -m \"{escapedMessage}\"");
|
|
|
|
if (_options.PushEnabled)
|
|
{
|
|
// push whatever HEAD points to, regardless of the local branch's own name, to the
|
|
// configured remote branch — avoids depending on the locally checked-out branch
|
|
// happening to be named the same as _options.Branch
|
|
RunGit(_options.LocalWorkingCopyPath, $"{BuildAuthArgs()}push origin HEAD:{_options.Branch}");
|
|
}
|
|
|
|
return changedCount;
|
|
}
|
|
|
|
private void EnsureRemoteConfigured()
|
|
{
|
|
var result = RunGitAllowNonZero(_options.LocalWorkingCopyPath, "remote get-url origin");
|
|
if (result.ExitCode != 0)
|
|
{
|
|
// .git existed (e.g. a manual `git init`, or a previous run that failed before
|
|
// completing its clone) but has no "origin" configured — fix it up rather than
|
|
// fail, so a half-set-up working copy self-heals on the next run
|
|
RunGit(_options.LocalWorkingCopyPath, $"remote add origin \"{_options.RemoteUrl}\"");
|
|
}
|
|
else if (result.StandardOutput.Trim() != _options.RemoteUrl)
|
|
{
|
|
RunGit(_options.LocalWorkingCopyPath, $"remote set-url origin \"{_options.RemoteUrl}\"");
|
|
}
|
|
}
|
|
|
|
private string BuildAuthArgs()
|
|
{
|
|
if (string.IsNullOrEmpty(_options.GitToken))
|
|
return "";
|
|
|
|
string username = string.IsNullOrEmpty(_options.GitUserName) ? _options.GitToken : _options.GitUserName;
|
|
string basicAuth = Convert.ToBase64String(Encoding.ASCII.GetBytes($"{username}:{_options.GitToken}"));
|
|
return $"-c http.extraHeader=\"AUTHORIZATION: basic {basicAuth}\" ";
|
|
}
|
|
|
|
private GitProcessResult RunGit(string workingDirectory, string arguments)
|
|
{
|
|
var result = RunGitAllowNonZero(workingDirectory, arguments);
|
|
if (result.ExitCode != 0)
|
|
{
|
|
throw new InvalidOperationException(
|
|
$"git {RedactAuth(arguments)} failed (exit code {result.ExitCode}) in '{workingDirectory}':{Environment.NewLine}{result.StandardError}{Environment.NewLine}{result.StandardOutput}");
|
|
}
|
|
return result;
|
|
}
|
|
|
|
private GitProcessResult RunGitAllowNonZero(string workingDirectory, string arguments)
|
|
{
|
|
var psi = new ProcessStartInfo
|
|
{
|
|
FileName = "git",
|
|
Arguments = arguments,
|
|
WorkingDirectory = workingDirectory,
|
|
RedirectStandardOutput = true,
|
|
RedirectStandardError = true,
|
|
UseShellExecute = false,
|
|
CreateNoWindow = true,
|
|
};
|
|
|
|
using (var process = new Process { StartInfo = psi })
|
|
{
|
|
try
|
|
{
|
|
process.Start();
|
|
}
|
|
catch (Win32Exception exp)
|
|
{
|
|
throw new InvalidOperationException(
|
|
"Could not start 'git'. Make sure Git for Windows is installed and 'git' is on PATH.", exp);
|
|
}
|
|
|
|
string stdout = process.StandardOutput.ReadToEnd();
|
|
string stderr = process.StandardError.ReadToEnd();
|
|
process.WaitForExit();
|
|
|
|
return new GitProcessResult { ExitCode = process.ExitCode, StandardOutput = stdout, StandardError = stderr };
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// keeps the auth token out of exception messages that might end up logged somewhere
|
|
/// </summary>
|
|
private static string RedactAuth(string arguments)
|
|
{
|
|
int idx = arguments.IndexOf("http.extraHeader", StringComparison.OrdinalIgnoreCase);
|
|
if (idx == -1)
|
|
return arguments;
|
|
|
|
int firstQuote = arguments.IndexOf('"', idx);
|
|
int endQuote = firstQuote >= 0 ? arguments.IndexOf('"', firstQuote + 1) : -1;
|
|
if (firstQuote == -1 || endQuote == -1)
|
|
return arguments;
|
|
|
|
return arguments.Substring(0, idx) + "http.extraHeader=<redacted> " + arguments.Substring(endQuote + 1);
|
|
}
|
|
|
|
private class GitProcessResult
|
|
{
|
|
public int ExitCode { get; set; }
|
|
public string StandardOutput { get; set; }
|
|
public string StandardError { get; set; }
|
|
}
|
|
}
|
|
}
|