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.
|
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
|
## [0.2.0] - 2026-02-15
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -17,6 +17,9 @@ A single-file terminal UI for common Git workflows.
|
|||||||
- Initialize local repository
|
- Initialize local repository
|
||||||
- Optionally create and publish a new remote repository
|
- Optionally create and publish a new remote repository
|
||||||
- Supports GitHub and Gitea
|
- 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
|
## Requirements
|
||||||
|
|
||||||
@@ -46,6 +49,11 @@ If the directory is not a Git repository, `git-tui` offers:
|
|||||||
- Initialize repository only
|
- Initialize repository only
|
||||||
- Initialize and publish to a new remote repository
|
- 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
|
## Gitea Setup
|
||||||
|
|
||||||
For Gitea publish flow, you can provide values interactively in the TUI or pre-set environment variables:
|
For Gitea publish flow, you can provide values interactively in the TUI or pre-set environment variables:
|
||||||
|
|||||||
@@ -9,11 +9,15 @@
|
|||||||
- `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.
|
- Handles startup bootstrap for non-repository directories.
|
||||||
|
- Handles startup onboarding for repositories without configured remotes.
|
||||||
- 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:
|
- Executes provider-specific publication for new remote repositories:
|
||||||
- GitHub via `gh` CLI.
|
- GitHub via `gh` CLI.
|
||||||
- Gitea via HTTPS API and token-authenticated initial push.
|
- 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.
|
- Implements file selection for staged and unstaged operations.
|
||||||
|
|
||||||
## Public API and Contracts
|
## Public API and Contracts
|
||||||
|
|||||||
@@ -44,6 +44,19 @@
|
|||||||
- `GITEA_TOKEN` or `GITEA_ACCESS_TOKEN` for API token default.
|
- `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.
|
- 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
|
## Key Commands in UI
|
||||||
|
|
||||||
- Main menu:
|
- Main menu:
|
||||||
|
|||||||
@@ -153,7 +153,12 @@ class GitTuiApp:
|
|||||||
self.status_message = "Ready."
|
self.status_message = "Ready."
|
||||||
|
|
||||||
def run(self) -> None:
|
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.set_cursor_visibility(0)
|
||||||
self.stdscr.keypad(True)
|
self.stdscr.keypad(True)
|
||||||
@@ -168,6 +173,8 @@ class GitTuiApp:
|
|||||||
"Could not initialize a Git repository in this directory.",
|
"Could not initialize a Git repository in this directory.",
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
if not self.handle_repo_without_remote_startup():
|
||||||
|
return
|
||||||
|
|
||||||
selected_index = 0
|
selected_index = 0
|
||||||
menu_size = len(self.MENU_ITEMS)
|
menu_size = len(self.MENU_ITEMS)
|
||||||
@@ -232,6 +239,289 @@ class GitTuiApp:
|
|||||||
return True
|
return True
|
||||||
continue
|
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(
|
def select_option_menu(
|
||||||
self,
|
self,
|
||||||
title: str,
|
title: str,
|
||||||
|
|||||||
Reference in New Issue
Block a user