From 5b1b2a757357d8ef6f06557846a46019b769ef3b Mon Sep 17 00:00:00 2001 From: LeonL <132875948+Lenrul@users.noreply.github.com> Date: Fri, 6 Nov 2026 11:42:58 -0800 Subject: [PATCH] update to support 413 refusal --- net.lenrui.git-auto-sync/CHANGELOG.md | 5 + .../Editor/GitAutoSync.cs | 149 +++++++++++++++++- .../Editor/GitAutoSyncSettings.cs | 97 ++++++++++++ .../Editor/GitAutoSyncSettings.cs.meta | 11 ++ net.lenrui.git-auto-sync/README.md | 25 ++- net.lenrui.git-auto-sync/package.json | 11 +- 6 files changed, 290 insertions(+), 8 deletions(-) create mode 100644 net.lenrui.git-auto-sync/Editor/GitAutoSyncSettings.cs create mode 100644 net.lenrui.git-auto-sync/Editor/GitAutoSyncSettings.cs.meta diff --git a/net.lenrui.git-auto-sync/CHANGELOG.md b/net.lenrui.git-auto-sync/CHANGELOG.md index 26d9a99..790510a 100644 --- a/net.lenrui.git-auto-sync/CHANGELOG.md +++ b/net.lenrui.git-auto-sync/CHANGELOG.md @@ -1,5 +1,10 @@ # 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 - First release: pull on Editor open, commit and push on Editor close, with Tools > Git Auto Sync menu. diff --git a/net.lenrui.git-auto-sync/Editor/GitAutoSync.cs b/net.lenrui.git-auto-sync/Editor/GitAutoSync.cs index 0767cb4..f9cf742 100644 --- a/net.lenrui.git-auto-sync/Editor/GitAutoSync.cs +++ b/net.lenrui.git-auto-sync/Editor/GitAutoSync.cs @@ -7,12 +7,16 @@ // 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) +// +// 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.Diagnostics; using System.IO; using System.Linq; using System.Text; +using System.Text.RegularExpressions; using UnityEditor; using UnityEditor.SceneManagement; using UnityEngine; @@ -98,6 +102,9 @@ namespace Lenrui.GitSync [MenuItem(MenuRoot + "Commit and Push Now", false, 21)] static void PushNow() { Guarded(() => CommitAndPush(false)); } + [MenuItem(MenuRoot + "Settings...", false, 40)] + static void OpenSettings() { SettingsService.OpenProjectSettings(GitAutoSyncSettingsProvider.SettingsPath); } + // ---------------------------------------------------------------- open: fetch + update 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); GitResult push; + string refspec; if (hasUpstream) { int ahead, behind; @@ -233,17 +241,45 @@ namespace Lenrui.GitSync if (ahead == 0) { Info("'" + branch + "' is already in sync.", !quitting); return true; } 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 { + refspec = "HEAD:refs/heads/" + branch; 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/, 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) 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(); return true; } @@ -282,6 +318,109 @@ namespace Lenrui.GitSync 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@[: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) { var r = Git(args); @@ -289,7 +428,7 @@ namespace Lenrui.GitSync return r.Ok && v.Length > 0 ? v : null; } - struct GitResult + internal struct GitResult { public int Code; 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. - 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) { @@ -319,6 +458,8 @@ namespace Lenrui.GitSync StandardErrorEncoding = Encoding.UTF8, }; 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 errors = new StringBuilder(); diff --git a/net.lenrui.git-auto-sync/Editor/GitAutoSyncSettings.cs b/net.lenrui.git-auto-sync/Editor/GitAutoSyncSettings.cs new file mode 100644 index 0000000..6dbdefe --- /dev/null +++ b/net.lenrui.git-auto-sync/Editor/GitAutoSyncSettings.cs @@ -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://git@ssh.example.com/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(); + } + } +} diff --git a/net.lenrui.git-auto-sync/Editor/GitAutoSyncSettings.cs.meta b/net.lenrui.git-auto-sync/Editor/GitAutoSyncSettings.cs.meta new file mode 100644 index 0000000..082dde7 --- /dev/null +++ b/net.lenrui.git-auto-sync/Editor/GitAutoSyncSettings.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: ec4b24c52eb94f88a28ae083fc626960 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/net.lenrui.git-auto-sync/README.md b/net.lenrui.git-auto-sync/README.md index d4fd505..85c6a29 100644 --- a/net.lenrui.git-auto-sync/README.md +++ b/net.lenrui.git-auto-sync/README.md @@ -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. +## 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@/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@` 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 **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. - **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. +- **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 diff --git a/net.lenrui.git-auto-sync/package.json b/net.lenrui.git-auto-sync/package.json index 5c2b2a6..96158ce 100644 --- a/net.lenrui.git-auto-sync/package.json +++ b/net.lenrui.git-auto-sync/package.json @@ -1,10 +1,15 @@ { "name": "net.lenrui.git-auto-sync", - "version": "1.0.0", + "version": "1.1.0", "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", - "keywords": ["git", "version control", "sync", "editor"], + "keywords": [ + "git", + "version control", + "sync", + "editor" + ], "author": { "name": "Len" }