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