Adds non-repo bootstrap and publish flows

### Added
- Non-repository startup flow offering init-only or init+publish paths.
- Provider selection for publishing (GitHub via CLI, Gitea via HTTPS API).
- Gitea repository creation and initial push over HTTPS with configurable base URL, owner, and token defaults.
- Root README and documentation updates describing bootstrap flow and environment variables.
This commit is contained in:
2026-02-15 13:39:43 -03:00
parent d6d5ca3fc3
commit 5e63c01bd2
6 changed files with 910 additions and 16 deletions

View File

@@ -4,6 +4,15 @@ All notable changes to this project will be documented in this file.
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
## [0.2.0] - 2026-02-15
### Added
- Added non-repository startup flow that offers repository initialization.
- Added provider selection in non-repository publish flow: GitHub or Gitea.
- Added Gitea repository creation over HTTPS API with configurable base URL, owner, and token defaults.
- Added root `README.md` for repository front-page usage, including install and bootstrap instructions.
## [0.1.1] - 2026-02-14
### Added

92
README.md Normal file
View File

@@ -0,0 +1,92 @@
# git-tui
A single-file terminal UI for common Git workflows.
`git-tui` gives you a keyboard-driven interface for staging, committing, syncing, and bootstrapping repositories without leaving the terminal.
![git-tui avatar](assets/logo-avatar.png)
## Highlights
- Single-file executable (`git-tui`)
- Curses-based terminal UI
- Stage all or select individual files
- Commit with message prompt
- Push, pull (rebase), fetch, status
- Non-repo bootstrap flow:
- Initialize local repository
- Optionally create and publish a new remote repository
- Supports GitHub and Gitea
## Requirements
- Linux or Unix-like terminal with curses support
- Python 3
- Git
- GitHub CLI `gh` only if you choose GitHub publish flow
- Gitea personal access token only if you choose Gitea publish flow
## Installation
```bash
chmod +x git-tui
sudo install -m 755 git-tui /usr/local/bin/gtui
```
## Quick Start
Run from any project directory:
```bash
gtui
```
If the directory is not a Git repository, `git-tui` offers:
- Initialize repository only
- Initialize and publish to a new remote repository
## Gitea Setup
For Gitea publish flow, you can provide values interactively in the TUI or pre-set environment variables:
```bash
export GITEA_URL="https://git.example.com"
export GITEA_OWNER="your-user-or-org"
export GITEA_TOKEN="your-token"
```
Supported variable names:
- `GITEA_URL` or `GITEA_BASE_URL`
- `GITEA_OWNER`
- `GITEA_TOKEN` or `GITEA_ACCESS_TOKEN`
Token handling behavior:
- Token is read from environment when present
- Otherwise token is requested in the TUI
- Token is not persisted by `git-tui`
## Keybindings
- Main menu:
- `Up/Down` or `j/k` move
- `Enter` run action
- `q` or `Esc` quit
- File picker:
- `Space` toggle
- `a` toggle all
- `Enter` confirm
- `q` or `Esc` cancel
## Documentation
- `doc/README.md`
- `doc/usage.md`
- `doc/architecture.md`
- `CHANGELOG.md`
## License
No license file is currently included in this repository.

View File

@@ -2,6 +2,10 @@
This directory contains project documentation for `git-tui`.
## Entry Points
- [`README.md`](../README.md) - repository front page and quick start.
## Modules
- [`doc/architecture.md`](architecture.md) - architecture and module responsibilities.

View File

@@ -8,8 +8,12 @@
- `git-tui`:
- Initializes and runs the curses application loop.
- Handles startup bootstrap for non-repository directories.
- Renders a menu-driven UI with keyboard navigation.
- Executes Git subprocess commands and displays command output.
- Executes provider-specific publication for new remote repositories:
- GitHub via `gh` CLI.
- Gitea via HTTPS API and token-authenticated initial push.
- Implements file selection for staged and unstaged operations.
## Public API and Contracts
@@ -17,8 +21,11 @@
- Command entry point: `git-tui` executable script.
- Runtime contract:
- Must be executed in a terminal that supports curses.
- Must be run from a directory inside a Git working tree.
- Must be run from a writable project directory.
- 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.
## Data Model

View File

@@ -5,6 +5,8 @@
- Linux or Unix-like terminal environment with curses support.
- Python 3 interpreter available as `python3`.
- Git installed and available in `PATH`.
- GitHub CLI `gh` installed and authenticated for GitHub repository creation. If using Gitea see below.
- Gitea API token for Gitea repository creation (read/write permissions for repository and organization) over HTTPS.
## Installation
@@ -19,11 +21,29 @@
## Running
- Enter any Git repository and run:
- Run from any project directory:
```bash
gtui
```
## Non-Repository Bootstrap Flow
- If the current directory is not a Git repository, `git-tui` offers setup actions:
- Initialize repository only.
- Initialize and publish to a new remote repository.
- Initialize and publish flow:
- Chooses publishing provider: GitHub or Gitea.
- Initializes a local Git repository.
- Stages all current files.
- Creates an initial commit.
- GitHub option creates and pushes via `gh repo create --source=. --remote=origin --push`.
- Gitea option creates via HTTPS API, configures `origin`, and pushes with HTTPS authentication using the provided token.
- Optional Gitea environment variables:
- `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.
## Key Commands in UI
- Main menu:

790
git-tui
View File

@@ -7,12 +7,19 @@ common Git commands from a keyboard-driven terminal interface.
from __future__ import annotations
import base64
import curses
import json
import os
import shutil
import shlex
import subprocess
import sys
from urllib import error as urllib_error
from urllib import parse as urllib_parse
from urllib import request as urllib_request
from dataclasses import dataclass
from typing import List, Optional, Sequence
from typing import Any, Dict, List, Optional, Sequence, Tuple
@dataclass(frozen=True)
@@ -39,6 +46,37 @@ class ChangedFile:
return f"{self.index_status}{self.worktree_status}"
def run_command(
command: Sequence[str],
extra_env: Optional[Dict[str, str]] = None,
) -> tuple[int, str]:
"""Executes a command and captures combined output.
Args:
command: Full command arguments to execute.
extra_env: Optional environment variables merged into process env.
Returns:
A tuple of `(exit_code, output_text)` where output includes both
standard output and standard error.
"""
process_env = os.environ.copy()
if extra_env:
process_env.update(extra_env)
completed = subprocess.run(
command,
check=False,
capture_output=True,
stdin=subprocess.DEVNULL,
text=True,
env=process_env,
)
output = (completed.stdout or "") + (completed.stderr or "")
return completed.returncode, output.rstrip()
def run_git(args: Sequence[str]) -> tuple[int, str]:
"""Executes a Git command and captures combined output.
@@ -51,14 +89,13 @@ def run_git(args: Sequence[str]) -> tuple[int, str]:
"""
command = ["git", *args]
completed = subprocess.run(
return run_command(
command,
check=False,
capture_output=True,
text=True,
extra_env={
"GIT_TERMINAL_PROMPT": "0",
"GCM_INTERACTIVE": "Never",
},
)
output = (completed.stdout or "") + (completed.stderr or "")
return completed.returncode, output.rstrip()
def parse_changed_files(short_status_output: str) -> List[ChangedFile]:
@@ -118,16 +155,20 @@ class GitTuiApp:
def run(self) -> None:
"""Runs the main menu event loop until the user quits."""
if not self.ensure_inside_git_repo():
self.show_output(
"Not a Git repository",
"Run git-tui inside a Git repository working directory.",
)
return
self.set_cursor_visibility(0)
self.stdscr.keypad(True)
if not self.ensure_inside_git_repo():
bootstrapped = self.handle_non_repo_startup()
if not bootstrapped:
return
if not self.ensure_inside_git_repo():
self.show_output(
"Repository setup failed",
"Could not initialize a Git repository in this directory.",
)
return
selected_index = 0
menu_size = len(self.MENU_ITEMS)
@@ -159,6 +200,694 @@ class GitTuiApp:
exit_code, output = run_git(["rev-parse", "--is-inside-work-tree"])
return exit_code == 0 and output.strip() == "true"
def handle_non_repo_startup(self) -> bool:
"""Runs an onboarding flow when launched outside a Git repository.
Returns:
True when a repository is available after the flow, otherwise False.
"""
while True:
action_name = self.select_option_menu(
"This directory is not a Git repository.",
"Select setup action",
[
("Initialize repository only", "init_only"),
(
"Initialize and publish to new remote repo",
"init_publish",
),
("Quit", "quit"),
],
)
if action_name is None or action_name == "quit":
self.status_message = "Canceled repository setup."
return False
if action_name == "init_only":
if self.initialize_repository(show_result=True):
return True
continue
if action_name == "init_publish":
if self.initialize_and_publish_repository():
return True
continue
def select_option_menu(
self,
title: str,
subtitle: str,
options: Sequence[tuple[str, str]],
) -> Optional[str]:
"""Displays a single-select menu and returns selected action.
Args:
title: Heading displayed at top of the selection screen.
subtitle: Secondary helper text shown under the heading.
options: Display labels and action names for each option.
Returns:
The selected action name, or None when canceled.
"""
selected_index = 0
while True:
self.stdscr.erase()
height, _ = self.stdscr.getmaxyx()
self.safe_addstr(0, 0, title, curses.A_BOLD)
self.safe_addstr(1, 0, subtitle)
for index, (label, _) in enumerate(options):
prefix = ">" if index == selected_index else " "
row_text = f"{prefix} {label}"
row_attr = (
curses.A_REVERSE if index == selected_index else curses.A_NORMAL
)
self.safe_addstr(3 + index, 0, row_text, row_attr)
self.safe_addstr(
height - 1,
0,
"Keys: Up/Down or j/k, Enter select, q or Esc cancel",
)
self.stdscr.refresh()
key = self.stdscr.getch()
if key in (ord("q"), 27):
return None
if key in (curses.KEY_UP, ord("k")):
selected_index = (selected_index - 1) % len(options)
continue
if key in (curses.KEY_DOWN, ord("j")):
selected_index = (selected_index + 1) % len(options)
continue
if key in (10, 13, curses.KEY_ENTER):
return options[selected_index][1]
def initialize_repository(self, show_result: bool) -> bool:
"""Initializes a Git repository in the current working directory.
Args:
show_result: Whether to present initialization output in UI.
Returns:
True when initialization succeeds, otherwise False.
"""
init_with_branch_command = ["git", "init", "-b", "main"]
exit_code, output = run_git(["init", "-b", "main"])
if exit_code == 0:
if show_result:
self.show_command_result(init_with_branch_command, exit_code, output)
self.status_message = "Repository initialized."
return True
fallback_command = ["git", "init"]
fallback_exit_code, fallback_output = run_git(["init"])
if fallback_exit_code != 0:
if show_result:
details = (
f"Attempt 1\n$ {shlex.join(init_with_branch_command)}\n\n"
f"{output or '(no output)'}\n\n"
f"Attempt 2\n$ {shlex.join(fallback_command)}\n\n"
f"{fallback_output or '(no output)'}"
)
self.show_output("Repository initialization failed", details)
self.status_message = "Repository initialization failed."
return False
run_git(["symbolic-ref", "HEAD", "refs/heads/main"])
if show_result:
self.show_command_result(
fallback_command,
fallback_exit_code,
fallback_output,
)
self.status_message = "Repository initialized."
return True
def initialize_and_publish_repository(self) -> bool:
"""Initializes a repository and publishes it to a selected provider.
Returns:
True when setup and publication succeed, otherwise False.
"""
provider = self.select_option_menu(
"Publish target",
"Choose where the new remote repository will be created",
[
("GitHub (gh CLI)", "github"),
("Gitea (HTTPS API)", "gitea"),
("Cancel", "cancel"),
],
)
if provider is None or provider == "cancel":
self.status_message = "Publishing canceled."
return False
if not self.initialize_repository(show_result=False):
return False
commit_message = self.prompt_input(
'Initial commit message (blank uses "Initial commit")'
)
if commit_message is None:
commit_message = "Initial commit"
if not self.prepare_initial_commit(commit_message):
return False
if provider == "github":
return self.publish_github_repository()
if provider == "gitea":
return self.publish_gitea_repository()
self.status_message = "Publishing canceled."
return False
def prepare_initial_commit(self, commit_message: str) -> bool:
"""Stages all changes and creates the initial local commit.
Args:
commit_message: Commit message to use for the initial commit.
Returns:
True when commit preparation succeeds, otherwise False.
"""
add_command = ["git", "add", "-A"]
add_exit_code, add_output = run_git(["add", "-A"])
if add_exit_code != 0:
self.show_command_result(add_command, add_exit_code, add_output)
return False
commit_command = ["git", "commit", "-m", commit_message]
commit_exit_code, commit_output = run_git(["commit", "-m", commit_message])
if commit_exit_code != 0 and "nothing to commit" in commit_output.lower():
commit_command = ["git", "commit", "--allow-empty", "-m", commit_message]
commit_exit_code, commit_output = run_git(
["commit", "--allow-empty", "-m", commit_message]
)
if commit_exit_code != 0:
self.show_command_result(commit_command, commit_exit_code, commit_output)
return False
return True
def select_repository_visibility(self) -> Optional[bool]:
"""Prompts for repository visibility.
Returns:
True for private visibility, False for public, or None when canceled.
"""
visibility = self.select_option_menu(
"Repository visibility",
"Choose visibility for the new remote repository",
[("Private", "private"), ("Public", "public"), ("Cancel", "cancel")],
)
if visibility is None or visibility == "cancel":
self.status_message = "Publishing canceled."
return None
return visibility == "private"
def publish_github_repository(self) -> bool:
"""Creates and pushes a repository on GitHub using `gh`.
Returns:
True when repository creation and push succeed, otherwise False.
"""
if not self.is_command_available("gh"):
self.show_output(
"GitHub CLI not found",
"Publishing to GitHub requires the `gh` command.\n"
"Install `gh`, authenticate it, then retry.",
)
self.status_message = "GitHub CLI not found."
return False
default_repo_name = os.path.basename(os.getcwd()) or "repository"
repo_name = self.prompt_input(
"GitHub repository name (owner/name or name, blank uses "
f'"{default_repo_name}")'
)
if repo_name is None:
repo_name = default_repo_name
is_private = self.select_repository_visibility()
if is_private is None:
return False
visibility_flag = "--private" if is_private else "--public"
create_command = [
"gh",
"repo",
"create",
repo_name,
visibility_flag,
"--source=.",
"--remote=origin",
"--push",
]
create_exit_code, create_output = run_command(create_command)
if create_exit_code != 0:
self.show_command_result(create_command, create_exit_code, create_output)
return False
visibility_label = "private" if is_private else "public"
summary = (
"Initialized this directory as a Git repository and published it.\n\n"
f"Provider: GitHub\n"
f"Repository: {repo_name}\n"
f"Visibility: {visibility_label}\n\n"
f"$ {shlex.join(create_command)}\n\n"
f"{create_output or '(no output)'}"
)
self.show_output("Repository published", summary)
self.status_message = f"Published repository: {repo_name}"
return True
def publish_gitea_repository(self) -> bool:
"""Creates and pushes a repository on Gitea over HTTPS.
Returns:
True when repository creation and push succeed, otherwise False.
"""
default_repo_name = os.path.basename(os.getcwd()) or "repository"
default_owner = os.getenv("GITEA_OWNER", "").strip()
default_base_url = (
os.getenv("GITEA_URL", "").strip()
or os.getenv("GITEA_BASE_URL", "").strip()
)
repo_input = self.prompt_input(
"Gitea repository (owner/name or name, blank uses "
f'"{default_repo_name}")'
)
if repo_input is None:
repo_input = default_repo_name
repo_input = repo_input.strip()
owner = default_owner
repo_name = repo_input
if "/" in repo_input:
owner_part, repo_part = repo_input.split("/", maxsplit=1)
owner = owner_part.strip()
repo_name = repo_part.strip()
if not repo_name:
self.status_message = "Invalid repository name."
return False
base_url_input = self.prompt_input(
"Gitea base URL (for example https://git.example.com"
f'{", blank uses " + default_base_url if default_base_url else ""})'
)
if base_url_input is None:
base_url_input = default_base_url
base_url = self.normalize_base_url(base_url_input or "")
if not base_url:
self.show_output(
"Missing Gitea base URL",
"A valid Gitea HTTPS base URL is required to create a repository.",
)
self.status_message = "Missing Gitea base URL."
return False
is_private = self.select_repository_visibility()
if is_private is None:
return False
token = (
os.getenv("GITEA_TOKEN", "").strip()
or os.getenv("GITEA_ACCESS_TOKEN", "").strip()
)
if not token:
token = self.prompt_secret("Gitea API token")
if token is None:
self.status_message = "Missing Gitea API token."
return False
created, response_data, response_body = self.create_gitea_repository(
base_url=base_url,
token=token,
owner=owner,
repo_name=repo_name,
is_private=is_private,
)
if not created:
self.show_output("Gitea repository creation failed", response_body)
self.status_message = "Gitea repository creation failed."
return False
clone_url = str(response_data.get("clone_url") or "").strip()
full_name = str(response_data.get("full_name") or "").strip()
if not clone_url:
if full_name:
clone_url = f"{base_url}/{full_name}.git"
elif owner:
clone_url = f"{base_url}/{owner}/{repo_name}.git"
if not clone_url:
self.show_output(
"Missing clone URL",
"Repository was created but no HTTPS clone URL was returned.",
)
self.status_message = "Repository created without clone URL."
return False
push_username = self.resolve_gitea_push_username(
base_url=base_url,
token=token,
response_data=response_data,
)
if not push_username:
self.status_message = "Missing Gitea username for push."
return False
if not self.configure_origin_remote(clone_url):
return False
if not self.push_current_branch_to_origin_with_token(
username=push_username,
token=token,
):
return False
visibility_label = "private" if is_private else "public"
summary_lines = [
"Initialized this directory as a Git repository and published it.",
"",
"Provider: Gitea",
f"Repository: {full_name or repo_name}",
f"Visibility: {visibility_label}",
f"Remote URL: {clone_url}",
]
self.show_output("Repository published", "\n".join(summary_lines))
self.status_message = f"Published repository: {full_name or repo_name}"
return True
def create_gitea_repository(
self,
base_url: str,
token: str,
owner: str,
repo_name: str,
is_private: bool,
) -> Tuple[bool, Dict[str, Any], str]:
"""Creates a Gitea repository via HTTP API.
Args:
base_url: Base HTTPS URL of the Gitea instance.
token: Personal access token used for API authentication.
owner: Optional owner or organization name.
repo_name: New repository name.
is_private: Whether repository visibility should be private.
Returns:
A tuple of `(success, response_data, message)` for UI handling.
"""
payload: Dict[str, Any] = {"name": repo_name, "private": is_private}
if owner:
endpoint = f"{base_url}/api/v1/orgs/{urllib_parse.quote(owner)}/repos"
else:
endpoint = f"{base_url}/api/v1/user/repos"
status_code, body_text, body_data = self.gitea_post_json(
url=endpoint,
token=token,
payload=payload,
)
if status_code in (200, 201):
return True, body_data, body_text or "(no output)"
message = str(body_data.get("message") or body_text or "request failed")
if owner and status_code in (403, 404):
message = (
f"{message}\n\n"
"Hint: For personal repositories, provide just the repository name."
)
failure_text = (
f"HTTP status: {status_code or 'request error'}\n"
f"Endpoint: {endpoint}\n\n"
f"{message}"
)
return False, body_data, failure_text
def gitea_post_json(
self,
url: str,
token: str,
payload: Dict[str, Any],
) -> Tuple[int, str, Dict[str, Any]]:
"""Sends a JSON POST request to Gitea and parses JSON response.
Args:
url: Absolute API endpoint URL.
token: Personal access token for authentication.
payload: JSON payload for request body.
Returns:
A tuple of `(status_code, response_text, response_data)`.
"""
body_bytes = json.dumps(payload).encode("utf-8")
request = urllib_request.Request(
url=url,
data=body_bytes,
method="POST",
headers={
"Accept": "application/json",
"Authorization": f"token {token}",
"Content-Type": "application/json",
},
)
try:
with urllib_request.urlopen(request, timeout=30) as response:
response_text = response.read().decode("utf-8", errors="replace")
response_data = self.parse_json_object(response_text)
return response.getcode(), response_text, response_data
except urllib_error.HTTPError as error:
response_text = error.read().decode("utf-8", errors="replace")
response_data = self.parse_json_object(response_text)
return error.code, response_text, response_data
except urllib_error.URLError as error:
return 0, str(error.reason), {}
def gitea_get_json(self, url: str, token: str) -> Tuple[int, str, Dict[str, Any]]:
"""Sends a JSON GET request to Gitea and parses JSON response.
Args:
url: Absolute API endpoint URL.
token: Personal access token for authentication.
Returns:
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}",
},
)
try:
with urllib_request.urlopen(request, timeout=30) as response:
response_text = response.read().decode("utf-8", errors="replace")
response_data = self.parse_json_object(response_text)
return response.getcode(), response_text, response_data
except urllib_error.HTTPError as error:
response_text = error.read().decode("utf-8", errors="replace")
response_data = self.parse_json_object(response_text)
return error.code, response_text, response_data
except urllib_error.URLError as error:
return 0, str(error.reason), {}
def resolve_gitea_push_username(
self,
base_url: str,
token: str,
response_data: Dict[str, Any],
) -> Optional[str]:
"""Resolves a username for authenticated HTTPS push operations.
Args:
base_url: Base HTTPS URL of the Gitea instance.
token: Personal access token used for API authentication.
response_data: Repository creation response data.
Returns:
A username string, or None when unavailable.
"""
status_code, _, user_data = self.gitea_get_json(
url=f"{base_url}/api/v1/user",
token=token,
)
if status_code in (200, 201):
api_login = str(user_data.get("login") or "").strip()
if api_login:
return api_login
owner_data = response_data.get("owner")
if isinstance(owner_data, dict):
owner_login = str(
owner_data.get("login") or owner_data.get("username") or ""
).strip()
if owner_login:
return owner_login
prompt_value = self.prompt_input("Gitea username for HTTPS push")
if prompt_value is None:
return None
username = prompt_value.strip()
if not username:
return None
return username
def parse_json_object(self, text: str) -> Dict[str, Any]:
"""Parses a JSON object string into a dictionary.
Args:
text: Raw JSON text from an API response.
Returns:
Parsed dictionary content, or an empty dictionary.
"""
if not text:
return {}
try:
value = json.loads(text)
except json.JSONDecodeError:
return {}
if isinstance(value, dict):
return value
return {}
def configure_origin_remote(self, remote_url: str) -> bool:
"""Adds or updates `origin` to the provided remote URL.
Args:
remote_url: Remote URL assigned to `origin`.
Returns:
True when remote configuration succeeds, otherwise False.
"""
check_exit_code, _ = run_git(["remote", "get-url", "origin"])
if check_exit_code == 0:
command = ["git", "remote", "set-url", "origin", remote_url]
exit_code, output = run_git(["remote", "set-url", "origin", remote_url])
else:
command = ["git", "remote", "add", "origin", remote_url]
exit_code, output = run_git(["remote", "add", "origin", remote_url])
if exit_code != 0:
self.show_command_result(command, exit_code, output)
return False
return True
def push_current_branch_to_origin(self) -> bool:
"""Pushes the current branch to `origin` and sets upstream tracking.
Returns:
True when the push succeeds, otherwise False.
"""
branch_name = self.current_branch()
if branch_name == "detached":
branch_name = "main"
command = ["git", "push", "-u", "origin", branch_name]
exit_code, output = run_git(["push", "-u", "origin", branch_name])
if exit_code != 0:
self.show_command_result(command, exit_code, output)
return False
return True
def push_current_branch_to_origin_with_token(
self,
username: str,
token: str,
) -> bool:
"""Pushes current branch to `origin` using HTTPS basic authentication.
Args:
username: Username used for HTTP basic authentication.
token: Token used as the HTTP basic password value.
Returns:
True when the push succeeds, otherwise False.
"""
branch_name = self.current_branch()
if branch_name == "detached":
branch_name = "main"
auth_value = base64.b64encode(
f"{username}:{token}".encode("utf-8")
).decode("ascii")
command = [
"git",
"-c",
f"http.extraHeader=Authorization: Basic {auth_value}",
"push",
"-u",
"origin",
branch_name,
]
exit_code, output = run_command(
command,
extra_env={
"GIT_TERMINAL_PROMPT": "0",
"GCM_INTERACTIVE": "Never",
},
)
if exit_code != 0:
details = (
"Push to origin failed.\n\n"
f"{output or '(no output)'}"
)
self.show_output("Push failed", details)
self.status_message = "Push to origin failed."
return False
return True
def normalize_base_url(self, raw_base_url: str) -> str:
"""Normalizes a host value into a usable HTTPS base URL.
Args:
raw_base_url: User-entered base URL or host.
Returns:
A normalized URL string, or an empty string when invalid.
"""
base_url = raw_base_url.strip()
if not base_url:
return ""
if not base_url.startswith(("http://", "https://")):
base_url = f"https://{base_url}"
parsed = urllib_parse.urlparse(base_url)
if not parsed.scheme or not parsed.netloc:
return ""
normalized_path = parsed.path.rstrip("/")
return f"{parsed.scheme}://{parsed.netloc}{normalized_path}".rstrip("/")
def is_command_available(self, command_name: str) -> bool:
"""Checks whether an executable is available in PATH.
Args:
command_name: Command name to look up.
Returns:
True when command exists in PATH, otherwise False.
"""
return shutil.which(command_name) is not None
def current_branch(self) -> str:
"""Determines the current branch name for header display.
@@ -434,6 +1163,39 @@ class GitTuiApp:
return None
return value
def prompt_secret(self, label: str) -> Optional[str]:
"""Prompts for a single line of hidden text input.
Args:
label: Prompt label displayed before the hidden input field.
Returns:
The entered secret value, or None when empty.
"""
height, width = self.stdscr.getmaxyx()
prompt = f"{label}: "
max_input_len = max(width - len(prompt) - 1, 1)
self.safe_addstr(height - 2, 0, " " * max(width - 1, 1))
self.safe_addstr(height - 2, 0, prompt)
self.stdscr.refresh()
curses.noecho()
self.set_cursor_visibility(1)
try:
raw_input = self.stdscr.getstr(height - 2, len(prompt), max_input_len)
except curses.error:
raw_input = b""
finally:
curses.noecho()
self.set_cursor_visibility(0)
value = raw_input.decode("utf-8", errors="ignore").strip()
if not value:
return None
return value
def select_files(
self, files: Sequence[ChangedFile], title: str
) -> List[str]: