From 5e63c01bd281c6bc6212f7505b8bd7b68ab8f366 Mon Sep 17 00:00:00 2001 From: Beda Schmid Date: Sun, 15 Feb 2026 13:39:43 -0300 Subject: [PATCH] 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. --- CHANGELOG.md | 9 + README.md | 92 ++++++ doc/README.md | 4 + doc/architecture.md | 9 +- doc/usage.md | 22 +- git-tui | 790 +++++++++++++++++++++++++++++++++++++++++++- 6 files changed, 910 insertions(+), 16 deletions(-) create mode 100644 README.md diff --git a/CHANGELOG.md b/CHANGELOG.md index ebf1319..6206f18 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md new file mode 100644 index 0000000..c1fe5e1 --- /dev/null +++ b/README.md @@ -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. diff --git a/doc/README.md b/doc/README.md index 8669af1..c872d83 100644 --- a/doc/README.md +++ b/doc/README.md @@ -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. diff --git a/doc/architecture.md b/doc/architecture.md index 086ba29..d8c5b7f 100644 --- a/doc/architecture.md +++ b/doc/architecture.md @@ -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 diff --git a/doc/usage.md b/doc/usage.md index e799bd8..f68dcc1 100644 --- a/doc/usage.md +++ b/doc/usage.md @@ -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: diff --git a/git-tui b/git-tui index 82e69b8..8f2fbec 100755 --- a/git-tui +++ b/git-tui @@ -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]: