Add onboarding to connect repos without remotes
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user