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. 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 ## [0.1.1] - 2026-02-14
### Added ### 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`. This directory contains project documentation for `git-tui`.
## Entry Points
- [`README.md`](../README.md) - repository front page and quick start.
## Modules ## Modules
- [`doc/architecture.md`](architecture.md) - architecture and module responsibilities. - [`doc/architecture.md`](architecture.md) - architecture and module responsibilities.

View File

@@ -8,8 +8,12 @@
- `git-tui`: - `git-tui`:
- Initializes and runs the curses application loop. - Initializes and runs the curses application loop.
- Handles startup bootstrap for non-repository directories.
- Renders a menu-driven UI with keyboard navigation. - Renders a menu-driven UI with keyboard navigation.
- Executes Git subprocess commands and displays command output. - 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. - Implements file selection for staged and unstaged operations.
## Public API and Contracts ## Public API and Contracts
@@ -17,8 +21,11 @@
- Command entry point: `git-tui` executable script. - Command entry point: `git-tui` executable script.
- Runtime contract: - Runtime contract:
- Must be executed in a terminal that supports curses. - 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 `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 ## Data Model

View File

@@ -5,6 +5,8 @@
- Linux or Unix-like terminal environment with curses support. - Linux or Unix-like terminal environment with curses support.
- Python 3 interpreter available as `python3`. - Python 3 interpreter available as `python3`.
- Git installed and available in `PATH`. - 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 ## Installation
@@ -19,11 +21,29 @@
## Running ## Running
- Enter any Git repository and run: - Run from any project directory:
```bash ```bash
gtui 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 ## Key Commands in UI
- Main menu: - Main menu:

790
git-tui
View File

@@ -7,12 +7,19 @@ common Git commands from a keyboard-driven terminal interface.
from __future__ import annotations from __future__ import annotations
import base64
import curses import curses
import json
import os
import shutil
import shlex import shlex
import subprocess import subprocess
import sys 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 dataclasses import dataclass
from typing import List, Optional, Sequence from typing import Any, Dict, List, Optional, Sequence, Tuple
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -39,6 +46,37 @@ class ChangedFile:
return f"{self.index_status}{self.worktree_status}" 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]: def run_git(args: Sequence[str]) -> tuple[int, str]:
"""Executes a Git command and captures combined output. """Executes a Git command and captures combined output.
@@ -51,14 +89,13 @@ def run_git(args: Sequence[str]) -> tuple[int, str]:
""" """
command = ["git", *args] command = ["git", *args]
completed = subprocess.run( return run_command(
command, command,
check=False, extra_env={
capture_output=True, "GIT_TERMINAL_PROMPT": "0",
text=True, "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]: def parse_changed_files(short_status_output: str) -> List[ChangedFile]:
@@ -118,16 +155,20 @@ class GitTuiApp:
def run(self) -> None: def run(self) -> None:
"""Runs the main menu event loop until the user quits.""" """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.set_cursor_visibility(0)
self.stdscr.keypad(True) 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 selected_index = 0
menu_size = len(self.MENU_ITEMS) menu_size = len(self.MENU_ITEMS)
@@ -159,6 +200,694 @@ class GitTuiApp:
exit_code, output = run_git(["rev-parse", "--is-inside-work-tree"]) exit_code, output = run_git(["rev-parse", "--is-inside-work-tree"])
return exit_code == 0 and output.strip() == "true" 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: def current_branch(self) -> str:
"""Determines the current branch name for header display. """Determines the current branch name for header display.
@@ -434,6 +1163,39 @@ class GitTuiApp:
return None return None
return value 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( def select_files(
self, files: Sequence[ChangedFile], title: str self, files: Sequence[ChangedFile], title: str
) -> List[str]: ) -> List[str]: