Stop hardcoding port

This commit is contained in:
2026-08-04 16:06:20 -03:00
parent 2cdd8c11a3
commit 6f7da8908b
6 changed files with 200 additions and 141 deletions
+4
View File
@@ -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@<web-host>` 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
+10 -6
View File
@@ -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:
+2 -2
View File
@@ -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
+3 -3
View File
@@ -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.
+150 -98
View File
@@ -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
)
+31 -32
View File
@@ -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: