divan/RMuseum/Utils/PublicDataExport/GitRepoPublisher.cs
Hamid Reza Mohammadi ca8c781b6c #498 git fix
2026-08-15 07:30:06 +03:30

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; }
}
}
}