From 6f7da8908b39e939c83c0133b95518fb56faa01e Mon Sep 17 00:00:00 2001 From: Beda Schmid Date: Tue, 4 Aug 2026 16:06:20 -0300 Subject: [PATCH] Stop hardcoding port --- CHANGELOG.md | 4 + README.md | 16 ++- doc/architecture.md | 4 +- doc/usage.md | 6 +- git-tui | 248 +++++++++++++++++++++++++----------------- tests/test_git_tui.py | 63 ++++++----- 6 files changed, 200 insertions(+), 141 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d421ead..4cedbe7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,10 @@ The format is based on Keep a Changelog, and this project adheres to Semantic Ve machine's configured SSH key instead of always forcing HTTPS authentication. - Fixed synchronization of unborn repositories, which previously attempted to push a nonexistent `main` ref. +- Fixed custom Gitea SSH endpoints by preserving or retrieving the advertised + clone URL instead of guessing `git@` on port 22. +- Fixed network authentication by allowing Git and SSH to use the real terminal + instead of disabling prompts globally. ## [0.2.0] - 2026-02-15 diff --git a/README.md b/README.md index bfb7685..b664bcf 100644 --- a/README.md +++ b/README.md @@ -75,8 +75,6 @@ Supported variable names: - `GITEA_URL` or `GITEA_BASE_URL` - `GITEA_OWNER` - `GITEA_TOKEN` or `GITEA_ACCESS_TOKEN` -- `GITEA_SSH_USER` (defaults to `git`) -- `GITEA_SSH_PORT` (only needed for a non-default SSH port) Token handling behavior: @@ -84,10 +82,11 @@ Token handling behavior: - Otherwise token is requested in the TUI - Token is not persisted by `git-tui` -When connecting an existing Gitea repository by `owner/name`, choose SSH to -use an SSH key already configured on the machine, or HTTPS to use a configured -Git credential helper. You can also paste a complete clone URL to use it -unchanged. +When connecting an existing Gitea repository, paste the complete clone URL or +provide `owner/name` and ask the Gitea API to return its exact clone endpoints. +The app does not derive an SSH endpoint from the web URL because the SSH user, +host, and port can differ. Private API lookups require a token; pasting the +clone URL does not. Creating a Gitea repository still uses an API token, because Git/SSH cannot create repositories. The Git remote transport is selected separately; choosing @@ -99,6 +98,11 @@ offers to merge, replace local history after creating a backup branch, or force-push local history with `--force-with-lease`. Uncommitted changes must be committed or stashed first and are never silently discarded. +Network Git commands temporarily leave the curses screen and use the real +terminal. SSH passphrase prompts, HTTPS username/token prompts, and interactive +credential helpers therefore behave as they do for Git commands run directly +from the shell. + ## Keybindings - Main menu: diff --git a/doc/architecture.md b/doc/architecture.md index a49fe7a..34fac39 100644 --- a/doc/architecture.md +++ b/doc/architecture.md @@ -17,7 +17,7 @@ - Gitea via HTTPS API, followed by an SSH or token-authenticated HTTPS push. - Executes provider-specific connection to existing remote repositories: - GitHub by resolving owner or URL input to `origin`. - - Gitea by resolving owner, base URL, and SSH/HTTPS transport or full URL input to `origin`. + - Gitea by preserving a full clone URL or retrieving exact SSH/HTTPS clone endpoints through the repository API. - Synchronizes Gitea connections by inspecting ancestry, fast-forwarding the side that is behind, or asking the user how to resolve divergent histories. - Preserves local history on a backup branch before a user-confirmed local @@ -34,7 +34,7 @@ - Requires `git` to be installed and available in `PATH`. - Requires `gh` in `PATH` only for GitHub publication. - Requires Gitea API token for Gitea publication. - - Runs Git subprocesses in non-interactive mode to avoid terminal credential prompt deadlocks. + - Temporarily suspends curses for network Git subprocesses so terminal authentication and credential helpers remain available. ## Data Model diff --git a/doc/usage.md b/doc/usage.md index e22ef82..ad0b54f 100644 --- a/doc/usage.md +++ b/doc/usage.md @@ -41,7 +41,7 @@ - `GITEA_URL` or `GITEA_BASE_URL` for instance URL default. - `GITEA_OWNER` for default owner or organization. - `GITEA_TOKEN` or `GITEA_ACCESS_TOKEN` for API token default. -- Git credential prompts are disabled inside `git-tui`; authentication issues are reported in the UI instead of opening an interactive terminal prompt. +- Network Git commands temporarily suspend curses and use the real terminal, allowing normal SSH passphrase, HTTPS credential, and credential-helper prompts. ## Existing Repository Without Remote Flow @@ -51,8 +51,8 @@ - Continue without remote configuration. - Quit. - Connect existing remote flow: - - Gitea accepts `owner/name` or full clone URL input, and uses `GITEA_URL` or `GITEA_BASE_URL` defaults when a base URL is needed. - - For Gitea `owner/name` input, chooses SSH (the default) or HTTPS transport. SSH uses `GITEA_SSH_USER` (default `git`) and optional `GITEA_SSH_PORT`; HTTPS uses the configured Git credential helper. + - A full clone URL is used unchanged, preserving custom SSH users, aliases, hosts, and ports. + - For `owner/name`, uses the Gitea API to obtain the instance's exact `ssh_url` or `clone_url`; private repository lookup requires an API token. - Configures remote `origin` to the selected repository URL. - Inspects both histories and synchronizes automatically when one side can be fast-forwarded. - If histories diverge, offers merge, replace-local-with-backup, and force-push-with-lease choices. diff --git a/git-tui b/git-tui index f0a12ee..02b4c69 100755 --- a/git-tui +++ b/git-tui @@ -425,7 +425,9 @@ class GitTuiApp: local_has_commit = self.has_local_commit() if not remote_branch: - heads_exit_code, heads_output = run_git(["ls-remote", "--heads", "origin"]) + heads_exit_code, heads_output = self.run_git_network( + ["ls-remote", "--heads", "origin"] + ) if heads_exit_code != 0: self.show_command_result( ["git", "ls-remote", "--heads", "origin"], @@ -453,7 +455,9 @@ class GitTuiApp: return self.push_head_to_remote_branch(self.current_local_branch()) fetch_command = ["git", "fetch", "origin", remote_branch] - fetch_exit_code, fetch_output = run_git(["fetch", "origin", remote_branch]) + fetch_exit_code, fetch_output = self.run_git_network( + ["fetch", "origin", remote_branch] + ) if fetch_exit_code != 0: self.show_command_result(fetch_command, fetch_exit_code, fetch_output) return False @@ -606,7 +610,7 @@ class GitTuiApp: def remote_default_branch(self, remote_name: str) -> str: """Finds the default branch advertised by a remote, if it has one.""" - exit_code, output = run_git( + exit_code, output = self.run_git_network( ["ls-remote", "--symref", remote_name, "HEAD"] ) if exit_code != 0: @@ -663,12 +667,60 @@ class GitTuiApp: args.append("--force-with-lease") args.extend(["origin", f"HEAD:refs/heads/{remote_branch}"]) command = ["git", *args] - exit_code, output = run_git(args) + exit_code, output = self.run_git_network(args, capture_stdout=False) if exit_code != 0: self.show_command_result(command, exit_code, output) return False return True + def run_git_network( + self, + args: Sequence[str], + capture_stdout: bool = True, + ) -> tuple[int, str]: + """Runs network Git with access to the user's real terminal. + + Curses is temporarily suspended so OpenSSH, Git credential helpers, + and HTTPS username/token prompts behave exactly as they do for a Git + command typed directly in the shell. + """ + + command = ["git", *args] + can_use_terminal = ( + hasattr(self, "stdscr") + and sys.stdin.isatty() + and sys.stdout.isatty() + ) + if not can_use_terminal: + return run_command(command) + + curses_suspended = False + try: + curses.def_prog_mode() + curses.endwin() + curses_suspended = True + completed = subprocess.run( + command, + check=False, + stdin=None, + stdout=subprocess.PIPE if capture_stdout else None, + stderr=None, + text=True, + env=os.environ.copy(), + ) + output = completed.stdout or "" + if completed.returncode != 0: + print("\nGit command failed. Press Enter to return to git-tui.") + try: + input() + except EOFError: + pass + return completed.returncode, output.rstrip() + finally: + if curses_suspended: + curses.reset_prog_mode() + self.stdscr.refresh() + def create_local_backup_branch(self) -> str: """Creates a uniquely named branch preserving the current HEAD.""" @@ -797,24 +849,90 @@ class GitTuiApp: self.status_message = "Missing Gitea base URL." return "" + resolution = self.select_option_menu( + "Resolve Gitea clone endpoint", + "The web URL does not determine the Git SSH user, host, or port", + [ + ("Paste exact clone URL from Gitea", "clone_url"), + ("Ask the Gitea API for clone URLs", "api"), + ("Cancel", "cancel"), + ], + ) + if resolution is None or resolution == "cancel": + self.status_message = "Remote connection canceled." + return "" + if resolution == "clone_url": + clone_url = self.prompt_input("Exact Gitea SSH or HTTPS clone URL") + normalized_clone_url = self.normalize_remote_input(clone_url or "") + if self.looks_like_clone_url(normalized_clone_url): + return normalized_clone_url + self.show_output( + "Invalid clone URL", + "Copy the SSH or HTTPS clone URL shown by Gitea for this " + "repository.", + ) + self.status_message = "Invalid Gitea clone URL." + return "" + + return self.resolve_existing_gitea_url_via_api( + repo_input=normalized_repo_input, + base_url=base_url, + ) + + def resolve_existing_gitea_url_via_api( + self, repo_input: str, base_url: str + ) -> str: + """Gets exact clone endpoints for an existing repository from Gitea.""" + + if "/" not in repo_input: + self.show_output( + "Invalid Gitea repository", + "Provide owner/name or paste a full clone URL.", + ) + return "" + owner, repo_name = repo_input.split("/", maxsplit=1) + owner = owner.strip() + repo_name = repo_name.removesuffix(".git").strip() + if not owner or not repo_name: + return "" + + token = ( + os.getenv("GITEA_TOKEN", "").strip() + or os.getenv("GITEA_ACCESS_TOKEN", "").strip() + ) + if not token: + token = self.prompt_secret( + "Gitea API token (blank only works for public repositories)" + ) or "" + + endpoint = ( + f"{base_url}/api/v1/repos/{urllib_parse.quote(owner, safe='')}/" + f"{urllib_parse.quote(repo_name, safe='')}" + ) + status_code, response_body, response_data = self.gitea_get_json( + url=endpoint, + token=token, + ) + if status_code != 200: + self.show_output( + "Could not resolve Gitea clone URLs", + f"HTTP status: {status_code or 'request error'}\n" + f"Endpoint: {endpoint}\n\n" + f"{response_body or 'Repository not found or authentication required.'}", + ) + self.status_message = "Could not resolve Gitea clone URLs." + return "" + transport = self.select_gitea_transport() if not transport: return "" - - remote_url = self.resolve_gitea_remote_url( - repo_input=normalized_repo_input, - base_url=base_url, - transport=transport, - ssh_user=os.getenv("GITEA_SSH_USER", "git").strip() or "git", - ssh_port=os.getenv("GITEA_SSH_PORT", "").strip(), - ) - if not remote_url: + response_key = "ssh_url" if transport == "ssh" else "clone_url" + remote_url = str(response_data.get(response_key) or "").strip() + if not self.looks_like_clone_url(remote_url): self.show_output( - "Invalid Gitea repository", - "Provide owner/name (for example acme/project) or a full " - "clone URL.", + "Missing clone URL", + f"Gitea did not return a usable {transport.upper()} clone URL.", ) - self.status_message = "Invalid Gitea repository." return "" return remote_url @@ -835,68 +953,6 @@ class GitTuiApp: return "" return transport - def resolve_gitea_remote_url( - self, - repo_input: str, - base_url: str, - transport: str = "https", - ssh_user: str = "git", - ssh_port: str = "", - ) -> str: - """Builds a Gitea clone URL from owner/name input. - - Args: - repo_input: Repository locator in owner/name format. - base_url: Base URL of the Gitea instance. - transport: Clone protocol, either ``ssh`` or ``https``. - ssh_user: SSH username used for SSH clone URLs. - ssh_port: Optional SSH port used for SSH clone URLs. - - Returns: - The computed clone URL, or an empty string when input is - invalid. - """ - - normalized_input = self.normalize_remote_input(repo_input) - if not normalized_input or "/" not in normalized_input: - return "" - owner, repo_name = normalized_input.split("/", maxsplit=1) - owner = owner.strip() - repo_name = repo_name.strip() - if not owner or not repo_name: - return "" - if repo_name.endswith(".git"): - repo_name = repo_name[:-4] - if not repo_name: - return "" - - if transport == "https": - return f"{base_url}/{owner}/{repo_name}.git" - if transport != "ssh": - return "" - - parsed_base_url = urllib_parse.urlparse(base_url) - ssh_host = parsed_base_url.hostname or "" - if not ssh_host or not ssh_user or any( - character in ssh_user for character in "@/: \t\r\n" - ): - return "" - if ssh_port: - try: - port_number = int(ssh_port) - except ValueError: - return "" - if port_number < 1 or port_number > 65535: - return "" - uri_host = f"[{ssh_host}]" if ":" in ssh_host else ssh_host - return ( - f"ssh://{ssh_user}@{uri_host}:{port_number}/" - f"{owner}/{repo_name}.git" - ) - if ":" in ssh_host: - return f"ssh://{ssh_user}@[{ssh_host}]/{owner}/{repo_name}.git" - return f"{ssh_user}@{ssh_host}:{owner}/{repo_name}.git" - def normalize_remote_input(self, raw_value: str) -> str: """Normalizes user-provided repository input. @@ -1281,13 +1337,7 @@ class GitTuiApp: remote_url = clone_url if transport == "ssh": - remote_url = ssh_url or self.resolve_gitea_remote_url( - repo_input=full_name or f"{owner}/{repo_name}", - base_url=base_url, - transport="ssh", - ssh_user=os.getenv("GITEA_SSH_USER", "git").strip() or "git", - ssh_port=os.getenv("GITEA_SSH_PORT", "").strip(), - ) + remote_url = ssh_url if not remote_url: self.show_output( "Missing SSH clone URL", @@ -1430,14 +1480,10 @@ class GitTuiApp: A tuple of `(status_code, response_text, response_data)`. """ - request = urllib_request.Request( - url=url, - method="GET", - headers={ - "Accept": "application/json", - "Authorization": f"token {token}", - }, - ) + headers = {"Accept": "application/json"} + if token: + headers["Authorization"] = f"token {token}" + request = urllib_request.Request(url=url, method="GET", headers=headers) try: with urllib_request.urlopen(request, timeout=30) as response: response_text = response.read().decode("utf-8", errors="replace") @@ -1545,7 +1591,9 @@ class GitTuiApp: if branch_name == "detached": branch_name = "main" command = ["git", "push", "-u", "origin", branch_name] - exit_code, output = run_git(["push", "-u", "origin", branch_name]) + exit_code, output = self.run_git_network( + ["push", "-u", "origin", branch_name], capture_stdout=False + ) if exit_code != 0: self.show_command_result(command, exit_code, output) return False @@ -1858,19 +1906,23 @@ class GitTuiApp: def action_push(self) -> None: """Pushes current branch changes to default remote.""" - exit_code, output = run_git(["push"]) + exit_code, output = self.run_git_network(["push"], capture_stdout=False) self.show_command_result(["git", "push"], exit_code, output) def action_pull(self) -> None: """Pulls remote changes using rebase mode.""" - exit_code, output = run_git(["pull", "--rebase"]) + exit_code, output = self.run_git_network( + ["pull", "--rebase"], capture_stdout=False + ) self.show_command_result(["git", "pull", "--rebase"], exit_code, output) def action_fetch(self) -> None: """Fetches updates from all remotes and prunes deleted refs.""" - exit_code, output = run_git(["fetch", "--all", "--prune"]) + exit_code, output = self.run_git_network( + ["fetch", "--all", "--prune"], capture_stdout=False + ) self.show_command_result( ["git", "fetch", "--all", "--prune"], exit_code, output ) diff --git a/tests/test_git_tui.py b/tests/test_git_tui.py index c842fe3..a50f9e2 100644 --- a/tests/test_git_tui.py +++ b/tests/test_git_tui.py @@ -24,47 +24,46 @@ class GiteaRemoteUrlTests(unittest.TestCase): def setUp(self) -> None: self.app = git_tui.GitTuiApp.__new__(git_tui.GitTuiApp) - def test_resolves_ssh_url_from_https_base_url(self) -> None: - remote_url = self.app.resolve_gitea_remote_url( - "owner/project", "https://git.example.com", transport="ssh" - ) + def test_exact_custom_ssh_clone_url_is_preserved(self) -> None: + expected_url = "ssh://forge@git.example.com:2222/owner/project.git" + self.app.prompt_input = lambda *_args: expected_url - self.assertEqual(remote_url, "git@git.example.com:owner/project.git") + remote_url = self.app.prompt_gitea_existing_remote_url() - def test_resolves_custom_ssh_user_and_port(self) -> None: - remote_url = self.app.resolve_gitea_remote_url( - "owner/project", - "https://git.example.com/gitea", - transport="ssh", - ssh_user="forge", - ssh_port="2222", - ) + self.assertEqual(remote_url, expected_url) + + def test_api_resolution_uses_giteas_advertised_ssh_url(self) -> None: + self.app.prompt_secret = lambda *_args: "token" + self.app.select_gitea_transport = lambda: "ssh" + requested_urls = [] + + def fake_get(url: str, token: str): + requested_urls.append((url, token)) + return 200, "", { + "ssh_url": "ssh://forge@ssh.example.com:2222/owner/project.git", + "clone_url": "https://git.example.com/owner/project.git", + } + + self.app.gitea_get_json = fake_get + + with mock.patch.dict( + os.environ, + {"GITEA_TOKEN": "", "GITEA_ACCESS_TOKEN": ""}, + clear=False, + ): + remote_url = self.app.resolve_existing_gitea_url_via_api( + "owner/project", "https://git.example.com" + ) self.assertEqual( remote_url, - "ssh://forge@git.example.com:2222/owner/project.git", + "ssh://forge@ssh.example.com:2222/owner/project.git", ) - - def test_still_supports_https(self) -> None: - remote_url = self.app.resolve_gitea_remote_url( - "owner/project", "https://git.example.com", transport="https" - ) - self.assertEqual( - remote_url, - "https://git.example.com/owner/project.git", + requested_urls, + [("https://git.example.com/api/v1/repos/owner/project", "token")], ) - def test_rejects_invalid_ssh_port(self) -> None: - remote_url = self.app.resolve_gitea_remote_url( - "owner/project", - "https://git.example.com", - transport="ssh", - ssh_port="not-a-port", - ) - - self.assertEqual(remote_url, "") - class RepositorySyncTests(unittest.TestCase): def setUp(self) -> None: