From 0d1ff65479791163d02a3528804a08414d0d1d83 Mon Sep 17 00:00:00 2001 From: Lenrui Date: Thu, 5 Nov 2026 23:51:06 -0800 Subject: [PATCH] updated gitdesk for 100mb limit bypass --- README.md | 7 +++ gitdesk.py | 129 +++++++++++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 132 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index bf9123f..c305574 100644 --- a/README.md +++ b/README.md @@ -82,6 +82,13 @@ Tip: put this folder on your PATH (Windows), or symlink it on Linux/macOS - **Switching branch blocked by your changes:** it offers *Stash & switch*. - **A network command needs a password, token, SSH passphrase or host-key confirmation:** click **Retry in terminal**. gitdesk steps aside so git can ask you, then comes back. +- **Push refused as too big over HTTPS (HTTP 413)**, e.g. by Cloudflare's 100 MB upload limit: + gitdesk offers **Push over SSH** with the SSH address filled in. You can change the address, + and it's remembered per repo. + - Tick *Always push this repository over SSH* to set git's push URL, so pushes go over SSH + while fetches stay on HTTPS. + - If the site is behind Cloudflare, SSH needs an address that reaches the server directly: + a DNS-only hostname or the server's IP. - **Another program has the repository busy** (GitHub Desktop, VS Code, Unity, OneDrive, antivirus): gitdesk waits and retries for a few seconds. If a `.git/index.lock` is left behind by a crashed git, it offers **Remove lock file & retry**. diff --git a/gitdesk.py b/gitdesk.py index 205eb99..73b05a7 100644 --- a/gitdesk.py +++ b/gitdesk.py @@ -195,6 +195,27 @@ def looks_lockish(r: Result) -> bool: return not residue or bool(LOCKISH_RE.search(residue)) +TOO_LARGE_RE = re.compile(r"HTTP[/ \d.]*\s413\b|error: 413\b|status(?: code)?:? 413\b|\b413 Request" + r"|Request Entity Too Large|Payload Too Large|Content Too Large", re.I) + + +def too_large(r: Result) -> bool: + """The server (or a proxy like Cloudflare) refused an HTTP upload as too big.""" + return bool(TOO_LARGE_RE.search(f"{r.err}\n{r.out}")) + + +def ssh_url_for(http_url: str) -> str: + """'https://git.example.net/Owner/Repo.git' -> 'git@git.example.net:Owner/Repo.git'.""" + m = re.match(r"^https?://(?:[^@/]+@)?([^/:]+)(?::\d+)?/(.+?)(?:\.git)?/*$", http_url.strip()) + return f"git@{m.group(1)}:{m.group(2)}.git" if m else "" + + +def user_ssh_command_set(path: str) -> bool: + if os.environ.get("GIT_SSH_COMMAND") or os.environ.get("GIT_SSH"): + return True + return run_git(path, ["config", "--get", "core.sshCommand"]).ok + + def hint_for(message: str) -> str: """Plain-English next step for common git errors.""" if "Filename too long" in message: @@ -1790,6 +1811,62 @@ class CloneDialog(ChooseFolderMixin, ModalScreen): self.dismiss(None) +class SshPushDialog(ModalScreen): + """Offered when an HTTPS push is refused as too large. Returns (ssh_url, always) or None.""" + BINDINGS = [Binding("escape", "cancel", "Cancel")] + + def __init__(self, error: str, suggestion: str) -> None: + super().__init__() + self.error, self.suggestion = error, suggestion + + def compose(self) -> ComposeResult: + with Vertical(classes="dialog wide"): + yield Label(Text("Push is too big for HTTPS", style="bold"), classes="dialog-title") + yield Static(Text(self.error, style="#f97583")) + yield Static(Text( + "\nThe server - or a proxy in front of it - refused this push because the upload is too big " + "(HTTP 413). Cloudflare's free plan, for example, stops any single upload over 100 MB. Git LFS " + "files upload one at a time so they usually get through, but the rest of the push is sent " + "in one piece. Pushing over SSH avoids the limit.")) + yield Label("SSH address of this repository", classes="field-label") + yield Input(value=self.suggestion, placeholder="git@host:owner/repo.git or ssh://git@host:22/owner/repo.git", + id="ssh-url", compact=True) + yield Static(Text( + "If the website goes through Cloudflare or another proxy, SSH has to use an address that reaches " + "the server directly - a DNS-only hostname or the server's IP, e.g. " + "ssh://git@203.0.113.10:22/owner/repo.git", style=C_MUTED), classes="hint") + yield Checkbox("Always push this repository over SSH (fetching stays on HTTPS)", value=True, + id="ssh-always", compact=True) + yield Static(id="ssh-error", classes="error") + with Horizontal(classes="dialog-buttons"): + yield Button("Push over SSH", variant="primary", name="push", compact=True) + yield Button("Cancel", name="cancel", compact=True) + + def on_mount(self) -> None: + self.query_one("#ssh-url", Input).focus() + + def on_input_submitted(self, event: Input.Submitted) -> None: + event.stop() + self._submit() + + def _submit(self) -> None: + url = self.query_one("#ssh-url", Input).value.strip() + if not url: + self.query_one("#ssh-error", Static).update(Text("Enter the SSH address.", style=C_RED)) + return + self.dismiss((url, self.query_one("#ssh-always", Checkbox).value)) + + def on_button_pressed(self, event: Button.Pressed) -> None: + event.stop() + if event.button.name == "push": + self._submit() + else: + self.dismiss(None) + + def action_cancel(self) -> None: + self.dismiss(None) + + ACTIONS = [ ("fetch", "Fetch"), ("pull", "Pull"), @@ -1938,6 +2015,7 @@ ModalScreen { align: center middle; background: rgba(0, 0, 0, 0.55); } .hint { height: auto; color: #959da5; } .error { height: auto; margin-top: 1; } #nr-readme { margin-top: 1; } +#ssh-always { margin-top: 1; } .path-row { height: auto; } .path-row Input { width: 1fr; } .path-row Button { margin-left: 1; min-width: 12; } @@ -2804,13 +2882,19 @@ class GitDesk(App): buttons = [("retry", "Try again", "primary"), ("close", "Close", "default")] return await self.ask("The repository is busy", body, buttons, cancel="close") - async def net(self, args: List[str], what: str) -> bool: - """Run a network git command in the background; on failure offer to retry.""" + async def net(self, args: List[str], what: str, push: Optional[Tuple[str, List[str]]] = None) -> bool: + """Run a network git command in the background; on failure offer to retry. + + push=(remote, push args): if an HTTPS push is refused as too big (HTTP 413), + offer to push over SSH instead. + """ while True: async with self._net_lock: r = await self.bg(*args, serialize=False) if r.ok: return True + if push and too_large(r) and self.push_url(push[0]).startswith(("http://", "https://")): + return await self.ssh_push_flow(push[0], push[1], r) tail = "\n".join(r.message.splitlines()[-10:]) body = Text.assemble((tail, "#f97583"), "\n\n", ("If git needs a password or token, an SSH passphrase, or wants you to " @@ -2824,6 +2908,41 @@ class GitDesk(App): if choice != "retry": return False + def push_url(self, remote: str) -> str: + r = self.git.run("config", "--get", f"remote.{remote}.pushurl", log=False) + return r.out.strip() if r.ok and r.out.strip() else (self.state.remote_urls.get(remote, "") if self.state else "") + + async def ssh_push_flow(self, remote: str, push_args: List[str], failed: Result) -> bool: + saved = self.git.run("config", "--get", "gitdesk.sshUrl", log=False).out.strip() + suggestion = saved or ssh_url_for(self.push_url(remote)) + detail = "\n".join(line for line in failed.message.splitlines() + if "413" in line or "RPC failed" in line or "hung up" in line)[-600:] + res = await self.push_screen_wait(SshPushDialog(detail or failed.message[-600:], suggestion)) + if not res: + return False + url, always = res + await self.bg("config", "gitdesk.sshUrl", url) # prefilled next time + prefix: List[str] = [] + if not await asyncio.to_thread(user_ssh_command_set, self.git.path): + # fail fast (instead of hanging for minutes) if the SSH port isn't reachable + prefix = ["-c", "core.sshCommand=ssh -o ConnectTimeout=15"] + if always: + r = await self.gitop("remote", "set-url", "--push", remote, url) + if not r.ok: + await self.fail("Couldn't save the SSH push address", r) + return False + args = prefix + ["push", *push_args] + else: + args = prefix + ["-c", f"remote.{remote}.pushurl={url}", "push", *push_args] + ok = await self.net(args, f"push to {remote} over SSH") + if ok and always: + self.notify("Pushed over SSH. This repository will push over SSH from now on; fetching still " + "uses HTTPS.", timeout=10) + elif not ok and always: + # didn't work - put pushing back the way it was + await self.gitop("config", "--unset", f"remote.{remote}.pushurl") + return ok + def run_in_terminal(self, args: List[str], cwd: Optional[str] = None) -> bool: rc = 1 try: @@ -3054,7 +3173,8 @@ class GitDesk(App): return remote = self.remote_name() if not st.has_upstream: - if await self.net(["push", "-u", remote, st.branch], f"publish to {remote}"): + if await self.net(["push", "-u", remote, st.branch], f"publish to {remote}", + push=(remote, ["-u", remote, st.branch])): self.notify(f"Published '{st.branch}' to {remote}.") return if not await self.do_fetch(): @@ -3077,7 +3197,8 @@ class GitDesk(App): return target = st.upstream[len(remote) + 1:] n = st.ahead - if await self.net(["push", remote, f"HEAD:refs/heads/{target}"], f"push to {remote}"): + if await self.net(["push", remote, f"HEAD:refs/heads/{target}"], f"push to {remote}", + push=(remote, [remote, f"HEAD:refs/heads/{target}"])): self.notify(f"Pushed {plural(n, 'commit')} to {remote}.") async def commit_flow(self) -> None: