Add onboarding to connect repos without remotes

This commit is contained in:
2026-02-21 11:31:11 -03:00
parent 5e63c01bd2
commit 945fdf3dd6
5 changed files with 324 additions and 1 deletions
+8
View File
@@ -4,6 +4,14 @@ 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.
## [Unreleased]
### Added
- Added startup onboarding for existing local repositories that have no remote configured.
- Added provider selection to connect a local repository to an existing GitHub or Gitea remote repository.
- Added optional post-connection push to set upstream tracking after configuring `origin`.
## [0.2.0] - 2026-02-15
### Added
+8
View File
@@ -17,6 +17,9 @@ A single-file terminal UI for common Git workflows.
- Initialize local repository
- Optionally create and publish a new remote repository
- Supports GitHub and Gitea
- Existing local repo without remote:
- Offers connection to an existing GitHub or Gitea remote repository
- Can continue without remote setup if preferred
## Requirements
@@ -46,6 +49,11 @@ If the directory is not a Git repository, `git-tui` offers:
- Initialize repository only
- Initialize and publish to a new remote repository
If the directory is already a Git repository but has no remote configured, `git-tui` offers:
- Connect to an existing remote repository
- Continue without remote
## Gitea Setup
For Gitea publish flow, you can provide values interactively in the TUI or pre-set environment variables:
+4
View File
@@ -9,11 +9,15 @@
- `git-tui`:
- Initializes and runs the curses application loop.
- Handles startup bootstrap for non-repository directories.
- Handles startup onboarding for repositories without configured remotes.
- 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.
- Executes provider-specific connection to existing remote repositories:
- GitHub by resolving owner or URL input to `origin`.
- Gitea by resolving owner and base URL or URL input to `origin`.
- Implements file selection for staged and unstaged operations.
## Public API and Contracts
+13
View File
@@ -44,6 +44,19 @@
- `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.
## Existing Repository Without Remote Flow
- If the current directory is already a Git repository and has no remotes configured, `git-tui` offers startup actions:
- Connect to an existing remote repository.
- Continue without remote configuration.
- Quit.
- Connect existing remote flow:
- Chooses provider: GitHub or Gitea.
- GitHub accepts `owner/name` or full clone URL input.
- Gitea accepts `owner/name` or full clone URL input, and uses `GITEA_URL` or `GITEA_BASE_URL` defaults when a base URL is needed.
- Configures remote `origin` to the selected repository URL.
- Optionally pushes current branch with upstream tracking (`git push -u origin <branch>`).
## Key Commands in UI
- Main menu:
+291 -1
View File
@@ -153,7 +153,12 @@ class GitTuiApp:
self.status_message = "Ready."
def run(self) -> None:
"""Runs the main menu event loop until the user quits."""
"""Runs startup checks and the main menu event loop.
The startup sequence ensures this directory is a Git repository and,
when no remotes are configured, offers a one-time onboarding flow to
connect the local repository to an existing remote.
"""
self.set_cursor_visibility(0)
self.stdscr.keypad(True)
@@ -168,6 +173,8 @@ class GitTuiApp:
"Could not initialize a Git repository in this directory.",
)
return
if not self.handle_repo_without_remote_startup():
return
selected_index = 0
menu_size = len(self.MENU_ITEMS)
@@ -232,6 +239,289 @@ class GitTuiApp:
return True
continue
def handle_repo_without_remote_startup(self) -> bool:
"""Offers remote setup when a local repository has no remotes.
Returns:
True when startup should continue, otherwise False.
"""
if self.has_any_remote():
return True
while True:
action_name = self.select_option_menu(
"This repository has no remote configured.",
"Select startup action",
[
(
"Connect to an existing remote repository",
"connect_existing_remote",
),
("Continue without remote", "continue_without_remote"),
("Quit", "quit"),
],
)
if action_name is None or action_name == "quit":
self.status_message = "Canceled startup."
return False
if action_name == "continue_without_remote":
self.status_message = "Continuing without remote."
return True
if action_name == "connect_existing_remote":
if self.connect_existing_remote_repository():
return True
if self.has_any_remote():
return True
continue
def has_any_remote(self) -> bool:
"""Checks whether at least one Git remote is configured.
Returns:
True when at least one remote exists, otherwise False.
"""
exit_code, output = run_git(["remote"])
if exit_code != 0:
return False
for line in output.splitlines():
if line.strip():
return True
return False
def connect_existing_remote_repository(self) -> bool:
"""Connects `origin` to an existing remote repository.
Supports provider-specific input for GitHub and Gitea, and optionally
pushes the current branch to set upstream tracking.
Returns:
True when the repository is connected, otherwise False.
"""
provider = self.select_option_menu(
"Connect existing remote",
"Choose remote provider",
[
("GitHub", "github"),
("Gitea", "gitea"),
("Cancel", "cancel"),
],
)
if provider is None or provider == "cancel":
self.status_message = "Remote connection canceled."
return False
remote_url = ""
if provider == "github":
remote_url = self.prompt_github_existing_remote_url()
if provider == "gitea":
remote_url = self.prompt_gitea_existing_remote_url()
if not remote_url:
return False
if not self.configure_origin_remote(remote_url):
return False
push_choice = self.select_option_menu(
"Push local branch now?",
"This sets upstream tracking on origin.",
[
("Push now", "push"),
("Skip push", "skip"),
("Cancel", "cancel"),
],
)
should_push = push_choice == "push"
if should_push and not self.push_current_branch_to_origin():
return False
remote_name = self.current_branch()
push_state = "yes" if should_push else "no"
details = (
"Connected local repository to an existing remote.\n\n"
f"Provider: {provider.capitalize()}\n"
f"Remote URL: {remote_url}\n"
f"Current branch: {remote_name}\n"
f"Pushed now: {push_state}"
)
self.show_output("Remote connected", details)
self.status_message = "Connected local repository to origin."
return True
def prompt_github_existing_remote_url(self) -> str:
"""Prompts for and resolves a GitHub remote URL.
Returns:
A normalized remote URL string, or an empty string on cancel or
invalid input.
"""
default_repo = os.getenv("GITHUB_REPOSITORY", "").strip()
prompt = (
"GitHub repository (owner/name or clone URL, blank uses "
f'"{default_repo}")'
if default_repo
else "GitHub repository (owner/name or clone URL)"
)
repo_input = self.prompt_input(prompt)
if repo_input is None:
repo_input = default_repo
remote_url = self.resolve_github_remote_url(repo_input or "")
if not remote_url:
self.show_output(
"Invalid GitHub repository",
"Provide owner/name (for example acme/project) or a full "
"clone URL.",
)
self.status_message = "Invalid GitHub repository."
return ""
return remote_url
def resolve_github_remote_url(self, repo_input: str) -> str:
"""Resolves GitHub input into a remote URL.
Args:
repo_input: Repository locator as owner/name or a clone URL.
Returns:
A usable remote URL, or an empty string when input is invalid.
"""
normalized_input = self.normalize_remote_input(repo_input)
if not normalized_input:
return ""
if self.looks_like_clone_url(normalized_input):
return normalized_input
if "/" not in normalized_input:
return ""
owner, repo_name = normalized_input.split("/", maxsplit=1)
owner = owner.strip()
repo_name = repo_name.strip()
if not owner or not repo_name:
return ""
if repo_name.endswith(".git"):
repo_name = repo_name[:-4]
return f"https://github.com/{owner}/{repo_name}.git"
def prompt_gitea_existing_remote_url(self) -> str:
"""Prompts for and resolves a Gitea remote URL.
Returns:
A normalized remote URL string, or an empty string on cancel or
invalid input.
"""
default_owner = os.getenv("GITEA_OWNER", "").strip()
default_repo_name = os.path.basename(os.getcwd()) or "repository"
default_repo = (
f"{default_owner}/{default_repo_name}" if default_owner else ""
)
default_base_url = (
os.getenv("GITEA_URL", "").strip()
or os.getenv("GITEA_BASE_URL", "").strip()
)
prompt = (
"Gitea repository (owner/name or clone URL, blank uses "
f'"{default_repo}")'
if default_repo
else "Gitea repository (owner/name or clone URL)"
)
repo_input = self.prompt_input(prompt)
if repo_input is None:
repo_input = default_repo
normalized_repo_input = self.normalize_remote_input(repo_input or "")
if not normalized_repo_input:
self.status_message = "Remote connection canceled."
return ""
if self.looks_like_clone_url(normalized_repo_input):
return normalized_repo_input
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 when using owner/name "
"repository format.",
)
self.status_message = "Missing Gitea base URL."
return ""
remote_url = self.resolve_gitea_remote_url(
repo_input=normalized_repo_input,
base_url=base_url,
)
if not remote_url:
self.show_output(
"Invalid Gitea repository",
"Provide owner/name (for example acme/project) or a full "
"clone URL.",
)
self.status_message = "Invalid Gitea repository."
return ""
return remote_url
def resolve_gitea_remote_url(self, repo_input: str, base_url: str) -> str:
"""Builds a Gitea HTTPS clone URL from owner/name input.
Args:
repo_input: Repository locator in owner/name format.
base_url: Base URL of the Gitea instance.
Returns:
The computed HTTPS clone URL, or an empty string when input is
invalid.
"""
normalized_input = self.normalize_remote_input(repo_input)
if not normalized_input or "/" not in normalized_input:
return ""
owner, repo_name = normalized_input.split("/", maxsplit=1)
owner = owner.strip()
repo_name = repo_name.strip()
if not owner or not repo_name:
return ""
if repo_name.endswith(".git"):
repo_name = repo_name[:-4]
return f"{base_url}/{owner}/{repo_name}.git"
def normalize_remote_input(self, raw_value: str) -> str:
"""Normalizes user-provided repository input.
Args:
raw_value: Raw repository or URL value from user input.
Returns:
A trimmed value without trailing slash characters.
"""
return raw_value.strip().rstrip("/")
def looks_like_clone_url(self, value: str) -> bool:
"""Checks whether a value appears to be a complete clone URL.
Args:
value: Candidate repository value to classify.
Returns:
True when value looks like URL or SSH clone syntax.
"""
if value.startswith("git@") or value.startswith("ssh://"):
return True
parsed = urllib_parse.urlparse(value)
return bool(parsed.scheme and parsed.netloc)
def select_option_menu(
self,
title: str,