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:
@@ -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
92
README.md
Normal 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.
|
||||
|
||||

|
||||
|
||||
## 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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
22
doc/usage.md
22
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:
|
||||
|
||||
790
git-tui
790
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]:
|
||||
|
||||
Reference in New Issue
Block a user