update to support 413 refusal

This commit is contained in:
LeonL committed 2026-11-06 11:42:58 -08:00
1 parent 5bb0d5e406
commit 5b1b2a7573
6 files changed
+290 -8

No files matched your search

+5
View File
@@ -1,5 +1,10 @@
# Changelog # Changelog
## [1.1.0] - 2026-11-06
- When an HTTPS push is rejected as too large (HTTP 413), retry it over SSH, which can be on a different host.
- Project Settings > Git Auto Sync page for the SSH host, user, port or full URL, with a Test SSH Connection button.
## [1.0.0] - 2026-11-05 ## [1.0.0] - 2026-11-05
- First release: pull on Editor open, commit and push on Editor close, with Tools > Git Auto Sync menu. - First release: pull on Editor open, commit and push on Editor close, with Tools > Git Auto Sync menu.
+145 -4
View File
@@ -7,12 +7,16 @@
// If anything fails you can stay open or quit anyway; your work is always committed locally first. // If anything fails you can stay open or quit anyway; your work is always committed locally first.
// //
// Menu: Tools > Git Auto Sync (on/off, ask-before-commit, and manual Pull / Commit and Push) // Menu: Tools > Git Auto Sync (on/off, ask-before-commit, and manual Pull / Commit and Push)
//
// If an HTTPS push is rejected as too large (HTTP 413, such as Cloudflare's 100 MB upload limit), the push is
// retried over SSH, which can be on a different host. Set it up in Project Settings > Git Auto Sync.
using System; using System;
using System.Diagnostics; using System.Diagnostics;
using System.IO; using System.IO;
using System.Linq; using System.Linq;
using System.Text; using System.Text;
using System.Text.RegularExpressions;
using UnityEditor; using UnityEditor;
using UnityEditor.SceneManagement; using UnityEditor.SceneManagement;
using UnityEngine; using UnityEngine;
@@ -98,6 +102,9 @@ namespace Lenrui.GitSync
[MenuItem(MenuRoot + "Commit and Push Now", false, 21)] [MenuItem(MenuRoot + "Commit and Push Now", false, 21)]
static void PushNow() { Guarded(() => CommitAndPush(false)); } static void PushNow() { Guarded(() => CommitAndPush(false)); }
[MenuItem(MenuRoot + "Settings...", false, 40)]
static void OpenSettings() { SettingsService.OpenProjectSettings(GitAutoSyncSettingsProvider.SettingsPath); }
// ---------------------------------------------------------------- open: fetch + update // ---------------------------------------------------------------- open: fetch + update
static void PullLatest(bool manual) static void PullLatest(bool manual)
@@ -214,6 +221,7 @@ namespace Lenrui.GitSync
"It will be pushed the next time you close Unity.\n\n" + fetch.Tail, quitting); "It will be pushed the next time you close Unity.\n\n" + fetch.Tail, quitting);
GitResult push; GitResult push;
string refspec;
if (hasUpstream) if (hasUpstream)
{ {
int ahead, behind; int ahead, behind;
@@ -233,17 +241,45 @@ namespace Lenrui.GitSync
if (ahead == 0) { Info("'" + branch + "' is already in sync.", !quitting); return true; } if (ahead == 0) { Info("'" + branch + "' is already in sync.", !quitting); return true; }
string mergeRef = GitValue("config --get branch." + branch + ".merge"); string mergeRef = GitValue("config --get branch." + branch + ".merge");
push = Git("push " + remote + " HEAD:" + mergeRef, "Pushing " + ahead + " commit(s)...", 900); refspec = "HEAD:" + mergeRef;
push = Git("push " + remote + " " + refspec, "Pushing " + ahead + " commit(s)...", 900);
} }
else else
{ {
refspec = "HEAD:refs/heads/" + branch;
push = Git("push -u " + remote + " HEAD", "Pushing new branch '" + branch + "'...", 900); push = Git("push -u " + remote + " HEAD", "Pushing new branch '" + branch + "'...", 900);
} }
// Too big for the HTTPS front end (e.g. Cloudflare's 100 MB request limit): send the same push over SSH.
string pushedTo = remote;
if (!push.Ok && TooLargeForHttp(push))
{
var settings = GitAutoSyncSettings.Load();
if (!settings.sshFallback)
return Fail("Committed locally, but the server rejected the push as too large (HTTP 413). Turn on the SSH fallback in " +
"Project Settings > Git Auto Sync, or push it over SSH yourself.\n\n" + push.Tail, quitting);
string sshWhy;
string sshUrl = ResolveSshUrl(settings, RemoteUrl(remote), out sshWhy);
if (sshUrl == null)
return Fail("Committed locally, but the server rejected the push as too large (HTTP 413), and " + sshWhy, quitting);
Debug.Log("[GitAutoSync] HTTPS push was rejected as too large (HTTP 413); retrying over SSH: " + sshUrl);
push = Git("push " + Quote(sshUrl) + " " + refspec, "Too large for HTTPS, pushing over SSH...", 1800, batchSsh: true);
if (!push.Ok)
return Fail("Committed locally. The push was too large for HTTPS (HTTP 413), and pushing over SSH to " + sshUrl +
" failed too. " + SshHint(push, sshUrl) + "It will be retried the next time you close Unity.\n\n" + push.Tail, quitting);
// Pushing to a URL doesn't move origin/<branch>, so fetch to bring the remote-tracking branch up to date.
Git("fetch " + remote, "Updating " + remote + "...", 120);
if (!hasUpstream) Git("branch --set-upstream-to=" + remote + "/" + branch);
pushedTo = remote + " over SSH (" + sshUrl + ")";
}
if (!push.Ok) if (!push.Ok)
return Fail("Committed locally, but the push failed. It will be retried the next time you close Unity.\n\n" + push.Tail, quitting); return Fail("Committed locally, but the push failed. It will be retried the next time you close Unity.\n\n" + push.Tail, quitting);
Info("Pushed '" + branch + "' to " + remote + ".", !quitting); Info("Pushed '" + branch + "' to " + pushedTo + ".", !quitting);
if (!quitting) AssetDatabase.Refresh(); if (!quitting) AssetDatabase.Refresh();
return true; return true;
} }
@@ -282,6 +318,109 @@ namespace Lenrui.GitSync
static int StashCount() { return Lines(Git("stash list").Out).Length; } static int StashCount() { return Lines(Git("stash list").Out).Length; }
// ---------------------------------------------------------------- SSH fallback
static bool TooLargeForHttp(GitResult r)
{
return Regex.IsMatch(r.Err + "\n" + r.Out, @"HTTP 413|error: 413|413 (Request Entity|Payload) Too Large", RegexOptions.IgnoreCase);
}
// The remote the current branch pushes to.
internal static string PushRemote()
{
string branch = CurrentBranch();
string remote = branch != null ? GitValue("config --get branch." + branch + ".remote") : null;
return remote ?? DefaultRemote();
}
internal static string RemoteUrl(string remote)
{
return remote == null ? null : GitValue("remote get-url --push " + remote);
}
// https://git.example.com/owner/repo.git -> ssh://git@<SSH host>[:port]/owner/repo.git
internal static string ResolveSshUrl(GitAutoSyncSettings s, string remoteUrl, out string why)
{
why = null;
string full = (s.sshUrlOverride ?? "").Trim();
if (full.Length > 0) return full;
if (string.IsNullOrEmpty(remoteUrl)) { why = "there's no git remote set up."; return null; }
Uri u;
if (!Uri.TryCreate(remoteUrl, UriKind.Absolute, out u) || (u.Scheme != Uri.UriSchemeHttps && u.Scheme != Uri.UriSchemeHttp))
{
why = "the remote (" + Redact(remoteUrl) + ") isn't an HTTPS address, so there's no SSH address to work out from it. " +
"Set a Full SSH URL in Project Settings > Git Auto Sync.";
return null;
}
string host = (s.sshHost ?? "").Trim();
string user = (s.sshUser ?? "").Trim();
if (host.StartsWith("ssh://", StringComparison.OrdinalIgnoreCase)) host = host.Substring(6);
host = host.TrimEnd('/');
int at = host.LastIndexOf('@');
if (at >= 0) { user = host.Substring(0, at); host = host.Substring(at + 1); }
if (host.Length == 0) host = u.Host;
if (user.Length == 0) user = "git";
return "ssh://" + user + "@" + host + (s.sshPort > 0 && s.sshPort != 22 ? ":" + s.sshPort : "") + u.AbsolutePath;
}
// For the Test button in Project Settings.
internal static void TestSshConnection(GitAutoSyncSettings s)
{
Guarded(() =>
{
string why;
if (!CheckRepo(out why)) { Info(why, true); return; }
string url = ResolveSshUrl(s, RemoteUrl(PushRemote()), out why);
if (url == null) { Warn("Can't work out an SSH address: " + why, true); return; }
var r = Git("ls-remote " + Quote(url) + " HEAD", "Connecting to " + url + "...", 60, batchSsh: true);
if (r.Ok) Info("SSH works. Pushes too large for HTTPS will go to:\n\n" + url, true);
else Warn("Couldn't reach " + url + " over SSH. " + SshHint(r, url) + "\n\n" + r.Tail, true);
});
}
static string SshHint(GitResult r, string sshUrl)
{
string text = r.Err + "\n" + r.Out;
if (text.Contains("Host key verification failed"))
return "This computer doesn't trust the SSH server's host key yet: run `ssh -T " + SshTarget(sshUrl) +
"` once in a terminal and answer yes. ";
if (text.Contains("Permission denied"))
return "The SSH key was refused, or it needs a passphrase: add this computer's public key to your account on the git server " +
"and load the key into ssh-agent. ";
if (Regex.IsMatch(text, "Could not resolve hostname|Connection refused|timed out|No route to host"))
return "Couldn't connect to the SSH server; check the SSH host and port in Project Settings > Git Auto Sync. ";
if (Regex.IsMatch(text, "does not appear to be a git repository|not found|Cannot find repository", RegexOptions.IgnoreCase))
return "The SSH server has no repository at that path; check the SSH settings or set a Full SSH URL. ";
return "";
}
// "-p 2222 git@host" for an ssh:// URL, "git@host" for git@host:owner/repo.git
static string SshTarget(string sshUrl)
{
Uri u;
if (Uri.TryCreate(sshUrl, UriKind.Absolute, out u) && u.Scheme == "ssh")
return (u.Port > 0 && u.Port != 22 ? "-p " + u.Port + " " : "") + (u.UserInfo.Length > 0 ? u.UserInfo + "@" : "") + u.Host;
int colon = sshUrl.IndexOf(':');
return colon > 0 ? sshUrl.Substring(0, colon) : sshUrl;
}
static bool UserConfiguredSsh()
{
return !string.IsNullOrEmpty(Environment.GetEnvironmentVariable("GIT_SSH_COMMAND")) ||
!string.IsNullOrEmpty(Environment.GetEnvironmentVariable("GIT_SSH")) ||
GitValue("config --get core.sshCommand") != null;
}
static string Redact(string url) { return Regex.Replace(url ?? "", "//[^/@]+@", "//"); } // drop user:token@
static string Quote(string s) { return "\"" + s.Replace("\"", "\\\"") + "\""; }
static string GitValue(string args) static string GitValue(string args)
{ {
var r = Git(args); var r = Git(args);
@@ -289,7 +428,7 @@ namespace Lenrui.GitSync
return r.Ok && v.Length > 0 ? v : null; return r.Ok && v.Length > 0 ? v : null;
} }
struct GitResult internal struct GitResult
{ {
public int Code; public int Code;
public string Out, Err; public string Out, Err;
@@ -306,7 +445,7 @@ namespace Lenrui.GitSync
} }
// Runs git in the project folder. With a progress title it shows a cancelable progress bar. // Runs git in the project folder. With a progress title it shows a cancelable progress bar.
static GitResult Git(string args, string progress = null, int timeoutSeconds = 30) static GitResult Git(string args, string progress = null, int timeoutSeconds = 30, bool batchSsh = false)
{ {
var psi = new ProcessStartInfo("git", args) var psi = new ProcessStartInfo("git", args)
{ {
@@ -319,6 +458,8 @@ namespace Lenrui.GitSync
StandardErrorEncoding = Encoding.UTF8, StandardErrorEncoding = Encoding.UTF8,
}; };
psi.EnvironmentVariables["GIT_TERMINAL_PROMPT"] = "0"; // fail instead of waiting on an invisible password prompt psi.EnvironmentVariables["GIT_TERMINAL_PROMPT"] = "0"; // fail instead of waiting on an invisible password prompt
if (batchSsh && !UserConfiguredSsh())
psi.EnvironmentVariables["GIT_SSH_COMMAND"] = "ssh -o BatchMode=yes -o ConnectTimeout=20"; // same: no hidden SSH prompts
var output = new StringBuilder(); var output = new StringBuilder();
var errors = new StringBuilder(); var errors = new StringBuilder();
@@ -0,0 +1,97 @@
// GitAutoSyncSettings.cs - part of the Git Auto Sync package (net.lenrui.git-auto-sync).
//
// Project Settings > Git Auto Sync. Saved to ProjectSettings/GitAutoSyncSettings.json, so it gets committed
// along with the project and every computer that works on it uses the same SSH fallback.
using System;
using System.IO;
using UnityEditor;
using UnityEngine;
namespace Lenrui.GitSync
{
[Serializable]
internal class GitAutoSyncSettings
{
public bool sshFallback = true;
public string sshHost = "";
public string sshUser = "git";
public int sshPort = 22;
public string sshUrlOverride = "";
static string FilePath
{
get { return Path.GetFullPath(Path.Combine(Application.dataPath, "../ProjectSettings/GitAutoSyncSettings.json")); }
}
public static GitAutoSyncSettings Load()
{
var s = new GitAutoSyncSettings();
try { if (File.Exists(FilePath)) JsonUtility.FromJsonOverwrite(File.ReadAllText(FilePath), s); }
catch (Exception e) { Debug.LogWarning("[GitAutoSync] Couldn't read " + FilePath + ": " + e.Message); }
return s;
}
public void Save()
{
try { File.WriteAllText(FilePath, JsonUtility.ToJson(this, true)); }
catch (Exception e) { Debug.LogWarning("[GitAutoSync] Couldn't save " + FilePath + ": " + e.Message); }
}
}
public static class GitAutoSyncSettingsProvider
{
public const string SettingsPath = "Project/Git Auto Sync";
static GitAutoSyncSettings settings;
static string remoteUrl;
static bool lookedUpRemote;
[SettingsProvider]
public static SettingsProvider Create()
{
return new SettingsProvider(SettingsPath, SettingsScope.Project)
{
label = "Git Auto Sync",
keywords = new[] { "git", "ssh", "push", "413", "cloudflare", "sync" },
guiHandler = search => Draw(),
deactivateHandler = () => { settings = null; lookedUpRemote = false; },
};
}
static void Draw()
{
if (settings == null) settings = GitAutoSyncSettings.Load();
if (!lookedUpRemote) { remoteUrl = GitAutoSync.RemoteUrl(GitAutoSync.PushRemote()); lookedUpRemote = true; }
var s = settings;
EditorGUIUtility.labelWidth = 200;
EditorGUILayout.LabelField("SSH fallback for large pushes", EditorStyles.boldLabel);
EditorGUILayout.HelpBox("If the server rejects an HTTPS push as too large (HTTP 413, such as Cloudflare's 100 MB upload limit), " +
"the same push is retried over SSH. The SSH host can be different from the HTTPS one.", MessageType.Info);
EditorGUI.BeginChangeCheck();
s.sshFallback = EditorGUILayout.Toggle("Retry over SSH on HTTP 413", s.sshFallback);
using (new EditorGUI.DisabledScope(!s.sshFallback))
{
s.sshHost = EditorGUILayout.TextField(new GUIContent("SSH Host", "For example ssh.example.com. Leave blank to use the HTTPS remote's host."), s.sshHost);
s.sshUser = EditorGUILayout.TextField(new GUIContent("SSH User", "Usually git."), s.sshUser);
s.sshPort = Mathf.Clamp(EditorGUILayout.IntField("SSH Port", s.sshPort), 1, 65535);
s.sshUrlOverride = EditorGUILayout.TextField(new GUIContent("Full SSH URL (optional)",
"Use this exact address instead of working one out, for example ssh://[email protected]/owner/repo.git"), s.sshUrlOverride);
EditorGUILayout.Space();
string why;
string url = GitAutoSync.ResolveSshUrl(s, remoteUrl, out why);
EditorGUILayout.LabelField("Large pushes go to", url ?? "Nowhere: " + why, EditorStyles.wordWrappedLabel);
if (GUILayout.Button("Test SSH Connection", GUILayout.Width(180)))
{
var snapshot = s;
EditorApplication.delayCall += () => GitAutoSync.TestSshConnection(snapshot);
}
}
if (EditorGUI.EndChangeCheck()) s.Save();
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: ec4b24c52eb94f88a28ae083fc626960
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
+24 -1
View File
@@ -7,6 +7,28 @@ Keeps a Unity project in step with its git remote.
If something can't be done safely (a conflict, no network, a failed push), it asks whether to **Quit Anyway** or **Stay Open**. Your work is always committed locally first, and an unpushed commit goes up the next time you close. If something can't be done safely (a conflict, no network, a failed push), it asks whether to **Quit Anyway** or **Stay Open**. Your work is always committed locally first, and an unpushed commit goes up the next time you close.
## Large pushes: SSH fallback for HTTP 413
If the server rejects an HTTPS push as too large (HTTP 413, such as Cloudflare's 100 MB upload limit), the same push is retried over SSH. The SSH address can be on a different host from the HTTPS one: `https://git.example.com/owner/repo.git` becomes `ssh://git@<SSH host>/owner/repo.git`.
Set it up in **Project Settings > Git Auto Sync** (or **Tools > Git Auto Sync > Settings...**):
- **SSH Host**: for example `ssh.example.com`. Leave blank to use the HTTPS remote's host.
- **SSH User** (usually `git`) and **SSH Port** (22 unless your server uses another).
- **Full SSH URL**: optional. Use it when the path differs, such as a server installed under a subpath.
- **Test SSH Connection**: checks the address, your key and the host key before you need the fallback.
The settings are saved to `ProjectSettings/GitAutoSyncSettings.json`, which is committed with the project, so every computer gets them.
SSH runs without prompts, so each computer needs two things set up once:
- The server's host key must be trusted. Run `ssh -T git@<SSH host>` once in a terminal and answer yes.
- A key that works without typing: load it into ssh-agent, and add its public key to your account on the git server.
If you've set `core.sshCommand` or `GIT_SSH`, that's used as-is.
Only HTTP 413 triggers the fallback; other push failures are reported as usual. Git LFS files upload through LFS's own transfer, which goes over HTTPS unless the server allows LFS over SSH (for Gitea, `LFS_ALLOW_PURE_SSH`).
## Install ## Install
**Embedded:** copy the `net.lenrui.git-auto-sync` folder into the project's `Packages/` folder. It becomes part of the project and is committed along with it. **Embedded:** copy the `net.lenrui.git-auto-sync` folder into the project's `Packages/` folder. It becomes part of the project and is committed along with it.
@@ -26,8 +48,9 @@ If something can't be done safely (a conflict, no network, a failed push), it as
- **Enabled**: on by default. - **Enabled**: on by default.
- **Ask Before Committing on Close**: off by default, so closing commits and pushes without a prompt. - **Ask Before Committing on Close**: off by default, so closing commits and pushes without a prompt.
- **Pull Latest Now** and **Commit and Push Now**: run either half by hand. - **Pull Latest Now** and **Commit and Push Now**: run either half by hand.
- **Settings...**: opens Project Settings > Git Auto Sync.
Settings are stored per project, per machine. The two toggles are stored per project, per machine. The SSH settings are shared through the project (see above).
## What gets committed ## What gets committed
+8 -3
View File
@@ -1,10 +1,15 @@
{ {
"name": "net.lenrui.git-auto-sync", "name": "net.lenrui.git-auto-sync",
"version": "1.0.0", "version": "1.1.0",
"displayName": "Git Auto Sync", "displayName": "Git Auto Sync",
"description": "Pulls the latest commits when the Unity Editor opens, and commits and pushes your changes when it closes. Stops and asks instead of guessing when there's a conflict, no network, or a failed push.", "description": "Pulls the latest commits when the Unity Editor opens, and commits and pushes your changes when it closes. Falls back to SSH when an HTTPS push is too large (HTTP 413). Stops and asks instead of guessing when there's a conflict, no network, or a failed push.",
"unity": "2019.4", "unity": "2019.4",
"keywords": ["git", "version control", "sync", "editor"], "keywords": [
"git",
"version control",
"sync",
"editor"
],
"author": { "author": {
"name": "Len" "name": "Len"
} }