Stop hardcoding port
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user