From 945fdf3dd6f0002c035f00c7b05cde07cfc84288 Mon Sep 17 00:00:00 2001 From: Beda Schmid Date: Sat, 21 Feb 2026 11:31:11 -0300 Subject: [PATCH] Add onboarding to connect repos without remotes --- CHANGELOG.md | 8 ++ README.md | 8 ++ doc/architecture.md | 4 + doc/usage.md | 13 ++ git-tui | 292 +++++++++++++++++++++++++++++++++++++++++++- 5 files changed, 324 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6206f18..19fe134 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index c1fe5e1..299915f 100644 --- a/README.md +++ b/README.md @@ -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: diff --git a/doc/architecture.md b/doc/architecture.md index d8c5b7f..773ee2a 100644 --- a/doc/architecture.md +++ b/doc/architecture.md @@ -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 diff --git a/doc/usage.md b/doc/usage.md index f68dcc1..7546118 100644 --- a/doc/usage.md +++ b/doc/usage.md @@ -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 `). + ## Key Commands in UI - Main menu: diff --git a/git-tui b/git-tui index 8f2fbec..f87d77e 100755 --- a/git-tui +++ b/git-tui @@ -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,