From 2cdd8c11a375362937ee2f238c9b6c95c280f310 Mon Sep 17 00:00:00 2001 From: Beda Schmid Date: Tue, 4 Aug 2026 15:53:15 -0300 Subject: [PATCH] Fix Git Flow --- CHANGELOG.md | 19 +- README.md | 20 +- doc/architecture.md | 7 +- doc/usage.md | 19 +- git-tui | 555 ++++++++++++++++++++++++++++++++++++++---- tests/test_git_tui.py | 162 ++++++++++++ 6 files changed, 720 insertions(+), 62 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 87372e2..d421ead 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,12 +1,5 @@ # Changelog -## Unreleased - -- Fixed existing Gitea remote setup so `owner/name` connections can use the - machine's configured SSH key instead of always forcing HTTPS authentication. -- Added an explicit SSH/HTTPS clone protocol choice and optional - `GITEA_SSH_USER`/`GITEA_SSH_PORT` configuration. - 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. @@ -18,6 +11,18 @@ The format is based on Keep a Changelog, and this project adheres to Semantic Ve - 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`. +- Added Gitea create-or-connect-and-sync actions for directories both with and + without an existing local Git repository. +- Added history-aware synchronization with fast-forward, merge, + replace-local-with-backup, and force-push-with-lease behavior. +- Added a conditional action for disconnecting local remote configuration. + +### Fixed + +- Fixed existing Gitea remote setup so `owner/name` connections can use the + machine's configured SSH key instead of always forcing HTTPS authentication. +- Fixed synchronization of unborn repositories, which previously attempted to + push a nonexistent `main` ref. ## [0.2.0] - 2026-02-15 diff --git a/README.md b/README.md index f36fd6a..bfb7685 100644 --- a/README.md +++ b/README.md @@ -47,13 +47,19 @@ gtui If the directory is not a Git repository, `git-tui` offers: - Initialize repository only -- Initialize and publish to a new remote repository +- Initialize, create a Gitea remote, and sync local commits to it +- Initialize, connect an existing Gitea remote, and synchronize both sides If the directory is already a Git repository but has no remote configured, `git-tui` offers: -- Connect to an existing remote repository +- Create a Gitea remote and sync +- Connect an existing Gitea remote and sync - Continue without remote +When a remote is configured, the normal operations menu includes an action to +disconnect it. Disconnecting removes only the local remote configuration; it +does not delete the Gitea repository. + ## Gitea Setup For Gitea publish flow, you can provide values interactively in the TUI or pre-set environment variables: @@ -83,6 +89,16 @@ use an SSH key already configured on the machine, or HTTPS to use a configured Git credential helper. You can also paste a complete clone URL to use it unchanged. +Creating a Gitea repository still uses an API token, because Git/SSH cannot +create repositories. The Git remote transport is selected separately; choosing +SSH leaves the new `origin` configured with Gitea's SSH clone URL. + +Synchronization compares local and remote commit history. It fast-forwards or +pushes automatically when one side is ahead. If the histories diverge, the UI +offers to merge, replace local history after creating a backup branch, or +force-push local history with `--force-with-lease`. Uncommitted changes must be +committed or stashed first and are never silently discarded. + ## Keybindings - Main menu: diff --git a/doc/architecture.md b/doc/architecture.md index cbc5660..a49fe7a 100644 --- a/doc/architecture.md +++ b/doc/architecture.md @@ -14,10 +14,15 @@ - 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. + - Gitea via HTTPS API, followed by an SSH or token-authenticated HTTPS push. - Executes provider-specific connection to existing remote repositories: - GitHub by resolving owner or URL input to `origin`. - Gitea by resolving owner, base URL, and SSH/HTTPS transport or full URL input to `origin`. + - Synchronizes Gitea connections by inspecting ancestry, fast-forwarding the + side that is behind, or asking the user how to resolve divergent histories. + - Preserves local history on a backup branch before a user-confirmed local + replacement and uses `--force-with-lease` for remote replacement. + - Removes local remote configuration through a conditional disconnect action. - Implements file selection for staged and unstaged operations. ## Public API and Contracts diff --git a/doc/usage.md b/doc/usage.md index da99809..e22ef82 100644 --- a/doc/usage.md +++ b/doc/usage.md @@ -30,14 +30,13 @@ - 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. + - Initialize, create a new Gitea remote repository, and sync. + - Initialize, connect an existing Gitea remote repository, and sync. +- Create and publish flow: - 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. + - Gitea creation uses the HTTPS API token, then configures and pushes `origin` using the separately selected SSH or HTTPS Git transport. - Optional Gitea environment variables: - `GITEA_URL` or `GITEA_BASE_URL` for instance URL default. - `GITEA_OWNER` for default owner or organization. @@ -47,16 +46,18 @@ ## 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. + - Connect to an existing Gitea remote repository and sync. + - Create a Gitea remote repository and sync (API token required). - 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. - For Gitea `owner/name` input, chooses SSH (the default) or HTTPS transport. SSH uses `GITEA_SSH_USER` (default `git`) and optional `GITEA_SSH_PORT`; HTTPS uses the configured Git credential helper. - Configures remote `origin` to the selected repository URL. - - Optionally pushes current branch with upstream tracking (`git push -u origin `). + - Inspects both histories and synchronizes automatically when one side can be fast-forwarded. + - If histories diverge, offers merge, replace-local-with-backup, and force-push-with-lease choices. + - Requires existing uncommitted changes to be committed or stashed before synchronization. +- Repositories with a configured remote expose `Disconnect remote` in the normal operations menu. This removes only the local Git remote configuration. ## Key Commands in UI diff --git a/git-tui b/git-tui index f147597..f0a12ee 100755 --- a/git-tui +++ b/git-tui @@ -163,7 +163,8 @@ class GitTuiApp: self.set_cursor_visibility(0) self.stdscr.keypad(True) - if not self.ensure_inside_git_repo(): + started_without_repository = not self.ensure_inside_git_repo() + if started_without_repository: bootstrapped = self.handle_non_repo_startup() if not bootstrapped: return @@ -173,14 +174,19 @@ class GitTuiApp: "Could not initialize a Git repository in this directory.", ) return - if not self.handle_repo_without_remote_startup(): + if ( + not started_without_repository + and not self.handle_repo_without_remote_startup() + ): return selected_index = 0 - menu_size = len(self.MENU_ITEMS) while True: - self.draw_main_screen(selected_index) + menu_items = self.main_menu_items() + menu_size = len(menu_items) + selected_index %= menu_size + self.draw_main_screen(selected_index, menu_items) key = self.stdscr.getch() if key in (ord("q"), 27): @@ -192,7 +198,7 @@ class GitTuiApp: selected_index = (selected_index + 1) % menu_size continue if key in (10, 13, curses.KEY_ENTER): - action_name = self.MENU_ITEMS[selected_index][1] + action_name = menu_items[selected_index][1] if action_name == "quit": return self.run_action(action_name) @@ -221,8 +227,12 @@ class GitTuiApp: [ ("Initialize repository only", "init_only"), ( - "Initialize and publish to new remote repo", - "init_publish", + "Initialize, create Gitea remote, and sync", + "init_create_gitea", + ), + ( + "Initialize, connect existing Gitea remote, and sync", + "init_connect_gitea", ), ("Quit", "quit"), ], @@ -234,8 +244,18 @@ class GitTuiApp: if self.initialize_repository(show_result=True): return True continue - if action_name == "init_publish": - if self.initialize_and_publish_repository(): + if action_name == "init_create_gitea": + if self.initialize_and_publish_gitea_repository(): + return True + if self.ensure_inside_git_repo(): + return True + continue + if action_name == "init_connect_gitea": + if not self.initialize_repository(show_result=False): + continue + if self.connect_existing_gitea_repository(sync=True): + return True + if self.ensure_inside_git_repo(): return True continue @@ -255,8 +275,12 @@ class GitTuiApp: "Select startup action", [ ( - "Connect to an existing remote repository", - "connect_existing_remote", + "Connect existing Gitea remote and sync", + "connect_existing_gitea", + ), + ( + "Create Gitea remote and sync (API token required)", + "create_gitea", ), ("Continue without remote", "continue_without_remote"), ("Quit", "quit"), @@ -268,8 +292,14 @@ class GitTuiApp: 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(): + if action_name == "connect_existing_gitea": + if self.connect_existing_gitea_repository(sync=True): + return True + if self.has_any_remote(): + return True + continue + if action_name == "create_gitea": + if self.publish_local_repository_to_gitea(): return True if self.has_any_remote(): return True @@ -350,6 +380,316 @@ class GitTuiApp: self.status_message = "Connected local repository to origin." return True + def connect_existing_gitea_repository(self, sync: bool = True) -> bool: + """Connects an existing Gitea repository and optionally synchronizes it. + + Args: + sync: Whether to reconcile local and remote history immediately. + + Returns: + True when the remote is connected and synchronization succeeds. + """ + + remote_url = self.prompt_gitea_existing_remote_url() + if not remote_url: + return False + + if self.has_local_commit() and not self.is_worktree_clean(): + self.show_output( + "Local changes need attention", + "Commit or stash local changes before connecting and syncing.\n\n" + "No remote was added and no local files were changed.", + ) + self.status_message = "Commit or stash changes before syncing." + return False + + if not self.configure_origin_remote(remote_url): + return False + if sync and not self.sync_origin_repository(): + return False + + details = ( + "Connected local repository to an existing Gitea remote.\n\n" + f"Remote URL: {remote_url}\n" + f"Current branch: {self.current_branch()}\n" + f"Synchronized: {'yes' if sync else 'no'}" + ) + self.show_output("Gitea remote connected", details) + self.status_message = "Connected and synchronized with origin." + return True + + def sync_origin_repository(self) -> bool: + """Safely reconciles the local repository with the ``origin`` remote.""" + + remote_branch = self.remote_default_branch("origin") + local_has_commit = self.has_local_commit() + + if not remote_branch: + heads_exit_code, heads_output = run_git(["ls-remote", "--heads", "origin"]) + if heads_exit_code != 0: + self.show_command_result( + ["git", "ls-remote", "--heads", "origin"], + heads_exit_code, + heads_output, + ) + return False + remote_branches = [] + for line in heads_output.splitlines(): + fields = line.split() + if len(fields) == 2 and fields[1].startswith("refs/heads/"): + remote_branches.append(fields[1].removeprefix("refs/heads/")) + if remote_branches: + local_branch = self.current_local_branch() + if local_branch in remote_branches: + remote_branch = local_branch + elif "main" in remote_branches: + remote_branch = "main" + else: + remote_branch = remote_branches[0] + + if not remote_branch: + if not local_has_commit and not self.create_initial_commit_for_sync(): + return False + return self.push_head_to_remote_branch(self.current_local_branch()) + + fetch_command = ["git", "fetch", "origin", remote_branch] + fetch_exit_code, fetch_output = run_git(["fetch", "origin", remote_branch]) + if fetch_exit_code != 0: + self.show_command_result(fetch_command, fetch_exit_code, fetch_output) + return False + + remote_ref = f"refs/remotes/origin/{remote_branch}" + if not local_has_commit: + if self.changed_files(): + if not self.create_initial_commit_for_sync(): + return False + local_has_commit = True + else: + command = ["git", "switch", "-C", remote_branch, remote_ref] + exit_code, output = run_git( + ["switch", "-C", remote_branch, remote_ref] + ) + if exit_code != 0: + command = ["git", "checkout", "-B", remote_branch, remote_ref] + exit_code, output = run_git( + ["checkout", "-B", remote_branch, remote_ref] + ) + if exit_code != 0: + self.show_command_result(command, exit_code, output) + return False + return self.set_branch_upstream(remote_branch, remote_branch) + + local_sha = self.git_revision("HEAD") + remote_sha = self.git_revision(remote_ref) + if not local_sha or not remote_sha: + self.show_output( + "Synchronization failed", + "Could not determine local or remote commit state.", + ) + return False + if local_sha == remote_sha: + return self.set_branch_upstream(self.current_local_branch(), remote_branch) + + if self.is_ancestor("HEAD", remote_ref): + command = ["git", "merge", "--ff-only", remote_ref] + exit_code, output = run_git(["merge", "--ff-only", remote_ref]) + if exit_code != 0: + self.show_command_result(command, exit_code, output) + return False + return self.set_branch_upstream(self.current_local_branch(), remote_branch) + + if self.is_ancestor(remote_ref, "HEAD"): + return self.push_head_to_remote_branch(remote_branch) + + return self.resolve_diverged_histories(remote_branch, remote_ref) + + def resolve_diverged_histories(self, remote_branch: str, remote_ref: str) -> bool: + """Prompts for a safe resolution when local and remote have diverged.""" + + choice = self.select_option_menu( + "Local and remote histories have diverged", + "Choose which history should win, or merge both", + [ + ("Merge remote into local, then push", "merge"), + ("Replace local commits with remote (creates backup branch)", "remote"), + ("Force-push local commits over remote", "local"), + ("Cancel", "cancel"), + ], + ) + if choice is None or choice == "cancel": + self.status_message = "Synchronization canceled; origin remains connected." + return False + + if choice == "merge": + command = [ + "git", + "merge", + "--allow-unrelated-histories", + remote_ref, + ] + exit_code, output = run_git( + ["merge", "--allow-unrelated-histories", remote_ref] + ) + if exit_code != 0: + self.show_command_result(command, exit_code, output) + self.status_message = "Merge needs manual conflict resolution." + return False + return self.push_head_to_remote_branch(remote_branch) + + if choice == "remote": + confirmed = self.confirm_destructive_sync( + "Replace local history?", + "A backup branch will preserve the current local commit.", + ) + if not confirmed: + return False + backup_branch = self.create_local_backup_branch() + if not backup_branch: + return False + command = ["git", "reset", "--hard", remote_ref] + exit_code, output = run_git(["reset", "--hard", remote_ref]) + if exit_code != 0: + self.show_command_result(command, exit_code, output) + return False + if not self.set_branch_upstream( + self.current_local_branch(), remote_branch + ): + return False + self.show_output( + "Local history replaced", + f"Local files now match {remote_ref}.\n\n" + f"Previous local commit preserved as: {backup_branch}\n" + "Untracked files were left untouched.", + ) + return True + + confirmed = self.confirm_destructive_sync( + "Overwrite remote history?", + "This force-pushes the current local commit using --force-with-lease.", + ) + if not confirmed: + return False + return self.push_head_to_remote_branch(remote_branch, force=True) + + def confirm_destructive_sync(self, title: str, subtitle: str) -> bool: + """Requests explicit confirmation for a history-replacing operation.""" + + choice = self.select_option_menu( + title, + subtitle, + [("Cancel", "cancel"), ("Yes, replace history", "confirm")], + ) + return choice == "confirm" + + def has_local_commit(self) -> bool: + """Returns whether ``HEAD`` resolves to a local commit.""" + + exit_code, _ = run_git(["rev-parse", "--verify", "HEAD"]) + return exit_code == 0 + + def is_worktree_clean(self) -> bool: + """Returns whether tracked, staged, and untracked files are clean.""" + + exit_code, output = run_git(["status", "--porcelain"]) + return exit_code == 0 and not output.strip() + + def create_initial_commit_for_sync(self) -> bool: + """Creates the initial local commit required for pushing a branch.""" + + commit_message = self.prompt_input( + 'Initial commit message (blank uses "Initial commit")' + ) + if commit_message is None: + commit_message = "Initial commit" + return self.prepare_initial_commit(commit_message) + + def remote_default_branch(self, remote_name: str) -> str: + """Finds the default branch advertised by a remote, if it has one.""" + + exit_code, output = run_git( + ["ls-remote", "--symref", remote_name, "HEAD"] + ) + if exit_code != 0: + return "" + for line in output.splitlines(): + if not line.startswith("ref: refs/heads/") or not line.endswith("\tHEAD"): + continue + return line.removeprefix("ref: refs/heads/").removesuffix("\tHEAD") + return "" + + def current_local_branch(self) -> str: + """Returns a usable current branch name, including unborn branches.""" + + exit_code, output = run_git(["symbolic-ref", "--short", "HEAD"]) + if exit_code == 0 and output.strip(): + return output.strip() + branch_name = self.current_branch() + return "main" if branch_name == "detached" else branch_name + + def git_revision(self, revision: str) -> str: + """Resolves a Git revision to its commit identifier.""" + + exit_code, output = run_git(["rev-parse", "--verify", revision]) + return output.strip() if exit_code == 0 else "" + + def is_ancestor(self, ancestor: str, descendant: str) -> bool: + """Returns whether one Git revision is an ancestor of another.""" + + exit_code, _ = run_git(["merge-base", "--is-ancestor", ancestor, descendant]) + return exit_code == 0 + + def set_branch_upstream(self, local_branch: str, remote_branch: str) -> bool: + """Sets a local branch to track an origin branch.""" + + command = [ + "git", + "branch", + f"--set-upstream-to=origin/{remote_branch}", + local_branch, + ] + exit_code, output = run_git(command[1:]) + if exit_code != 0: + self.show_command_result(command, exit_code, output) + return False + return True + + def push_head_to_remote_branch( + self, remote_branch: str, force: bool = False + ) -> bool: + """Pushes local HEAD to a named branch and configures tracking.""" + + args = ["push", "-u"] + if force: + args.append("--force-with-lease") + args.extend(["origin", f"HEAD:refs/heads/{remote_branch}"]) + command = ["git", *args] + exit_code, output = run_git(args) + if exit_code != 0: + self.show_command_result(command, exit_code, output) + return False + return True + + def create_local_backup_branch(self) -> str: + """Creates a uniquely named branch preserving the current HEAD.""" + + exit_code, short_sha = run_git(["rev-parse", "--short", "HEAD"]) + if exit_code != 0: + return "" + short_sha = short_sha.strip() + base_name = f"gtui-backup-{short_sha}" + branch_name = base_name + suffix = 2 + while run_git(["show-ref", "--verify", f"refs/heads/{branch_name}"])[0] == 0: + branch_name = f"{base_name}-{suffix}" + suffix += 1 + exit_code, output = run_git(["branch", branch_name, "HEAD"]) + if exit_code != 0: + self.show_command_result( + ["git", "branch", branch_name, "HEAD"], exit_code, output + ) + return "" + return branch_name + def prompt_github_existing_remote_url(self) -> str: """Prompts for and resolves a GitHub remote URL. @@ -457,17 +797,8 @@ class GitTuiApp: self.status_message = "Missing Gitea base URL." return "" - transport = self.select_option_menu( - "Gitea clone protocol", - "Use the protocol already configured for this Gitea instance", - [ - ("SSH (uses your configured SSH key)", "ssh"), - ("HTTPS (uses a Git credential helper)", "https"), - ("Cancel", "cancel"), - ], - ) - if transport is None or transport == "cancel": - self.status_message = "Remote connection canceled." + transport = self.select_gitea_transport() + if not transport: return "" remote_url = self.resolve_gitea_remote_url( @@ -487,6 +818,23 @@ class GitTuiApp: return "" return remote_url + def select_gitea_transport(self) -> str: + """Prompts for the Git transport used by a Gitea remote.""" + + transport = self.select_option_menu( + "Gitea clone protocol", + "Use the protocol already configured for this Gitea instance", + [ + ("SSH (uses your configured SSH key)", "ssh"), + ("HTTPS (uses a Git credential helper)", "https"), + ("Cancel", "cancel"), + ], + ) + if transport is None or transport == "cancel": + self.status_message = "Remote connection canceled." + return "" + return transport + def resolve_gitea_remote_url( self, repo_input: str, @@ -710,6 +1058,31 @@ class GitTuiApp: self.status_message = "Publishing canceled." return False + def initialize_and_publish_gitea_repository(self) -> bool: + """Initializes locally, creates a Gitea repository, and pushes to it.""" + + if not self.initialize_repository(show_result=False): + return False + if not self.create_initial_commit_for_sync(): + return False + return self.publish_gitea_repository() + + def publish_local_repository_to_gitea(self) -> bool: + """Creates a Gitea remote for the current local repository and syncs it.""" + + if not self.has_local_commit(): + if not self.create_initial_commit_for_sync(): + return False + elif not self.is_worktree_clean(): + self.show_output( + "Local changes need attention", + "Commit or stash local changes before creating and syncing a " + "Gitea remote.\n\nNo remote was created.", + ) + self.status_message = "Commit or stash changes before syncing." + return False + return self.publish_gitea_repository() + def prepare_initial_commit(self, commit_message: str) -> bool: """Stages all changes and creates the initial local commit. @@ -813,7 +1186,7 @@ class GitTuiApp: return True def publish_gitea_repository(self) -> bool: - """Creates and pushes a repository on Gitea over HTTPS. + """Creates a Gitea repository through its API and pushes with Git. Returns: True when repository creation and push succeed, otherwise False. @@ -860,6 +1233,10 @@ class GitTuiApp: self.status_message = "Missing Gitea base URL." return False + transport = self.select_gitea_transport() + if not transport: + return False + is_private = self.select_repository_visibility() if is_private is None: return False @@ -869,7 +1246,7 @@ class GitTuiApp: or os.getenv("GITEA_ACCESS_TOKEN", "").strip() ) if not token: - token = self.prompt_secret("Gitea API token") + token = self.prompt_secret("Gitea API token (required to create remote)") if token is None: self.status_message = "Missing Gitea API token." return False @@ -887,6 +1264,7 @@ class GitTuiApp: return False clone_url = str(response_data.get("clone_url") or "").strip() + ssh_url = str(response_data.get("ssh_url") or "").strip() full_name = str(response_data.get("full_name") or "").strip() if not clone_url: if full_name: @@ -901,22 +1279,44 @@ class GitTuiApp: 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 + remote_url = clone_url + if transport == "ssh": + remote_url = ssh_url or self.resolve_gitea_remote_url( + repo_input=full_name or f"{owner}/{repo_name}", + base_url=base_url, + transport="ssh", + ssh_user=os.getenv("GITEA_SSH_USER", "git").strip() or "git", + ssh_port=os.getenv("GITEA_SSH_PORT", "").strip(), + ) + if not remote_url: + self.show_output( + "Missing SSH clone URL", + "Repository was created, but an SSH clone URL could not be " + "resolved. Configure origin manually using the clone URL " + "shown by Gitea.", + ) + self.status_message = "Repository created without SSH clone URL." + 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, - ): + if not self.configure_origin_remote(remote_url): return False + if transport == "ssh": + if not self.push_head_to_remote_branch(self.current_local_branch()): + return False + else: + 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.push_current_branch_to_origin_with_token( + username=push_username, + token=token, + ): + return False visibility_label = "private" if is_private else "public" summary_lines = [ @@ -925,7 +1325,7 @@ class GitTuiApp: "Provider: Gitea", f"Repository: {full_name or repo_name}", f"Visibility: {visibility_label}", - f"Remote URL: {clone_url}", + f"Remote URL: {remote_url}", ] self.show_output("Repository published", "\n".join(summary_lines)) self.status_message = f"Published repository: {full_name or repo_name}" @@ -1258,11 +1658,36 @@ class GitTuiApp: return [] return parse_changed_files(output) - def draw_main_screen(self, selected_index: int) -> None: + def main_menu_items(self) -> List[tuple[str, str]]: + """Builds the main menu, including remote-only operations when useful.""" + + menu_items = list(self.MENU_ITEMS) + has_remote = self.has_any_remote() + if not has_remote: + menu_items = [ + item + for item in menu_items + if item[1] not in ("push", "pull", "fetch") + ] + else: + status_index = next( + index + for index, (_, action_name) in enumerate(menu_items) + if action_name == "status" + ) + menu_items.insert(status_index, ("Disconnect remote", "disconnect_remote")) + return menu_items + + def draw_main_screen( + self, + selected_index: int, + menu_items: Optional[Sequence[tuple[str, str]]] = None, + ) -> None: """Renders the main menu with repository summary information. Args: selected_index: Index of the currently highlighted menu item. + menu_items: Menu entries to render; defaults to current dynamic menu. """ self.stdscr.erase() @@ -1277,7 +1702,8 @@ class GitTuiApp: curses.A_BOLD, ) - for idx, (label, _) in enumerate(self.MENU_ITEMS): + displayed_items = menu_items or self.main_menu_items() + for idx, (label, _) in enumerate(displayed_items): prefix = ">" if idx == selected_index else " " row_text = f"{prefix} {label}" attr = curses.A_REVERSE if idx == selected_index else curses.A_NORMAL @@ -1355,6 +1781,9 @@ class GitTuiApp: if action_name == "fetch": self.action_fetch() return + if action_name == "disconnect_remote": + self.action_disconnect_remote() + return if action_name == "status": self.action_status() return @@ -1446,6 +1875,46 @@ class GitTuiApp: ["git", "fetch", "--all", "--prune"], exit_code, output ) + def action_disconnect_remote(self) -> None: + """Selects and removes a configured Git remote after confirmation.""" + + exit_code, output = run_git(["remote"]) + remote_names = [line.strip() for line in output.splitlines() if line.strip()] + if exit_code != 0 or not remote_names: + self.status_message = "No remote is configured." + return + + if len(remote_names) == 1: + remote_name = remote_names[0] + else: + options = [(name, name) for name in remote_names] + options.append(("Cancel", "cancel")) + selected = self.select_option_menu( + "Disconnect remote", + "Choose which remote configuration to remove", + options, + ) + if selected is None or selected == "cancel": + self.status_message = "Disconnect canceled." + return + remote_name = selected + + remote_url_exit, remote_url = run_git(["remote", "get-url", remote_name]) + if remote_url_exit != 0: + remote_url = "(URL unavailable)" + confirmation = self.select_option_menu( + f'Disconnect remote "{remote_name}"?', + remote_url, + [("Cancel", "cancel"), ("Remove remote configuration", "remove")], + ) + if confirmation != "remove": + self.status_message = "Disconnect canceled." + return + + command = ["git", "remote", "remove", remote_name] + remove_exit, remove_output = run_git(["remote", "remove", remote_name]) + self.show_command_result(command, remove_exit, remove_output) + def action_status(self) -> None: """Shows detailed repository status output.""" diff --git a/tests/test_git_tui.py b/tests/test_git_tui.py index 0467bcb..c842fe3 100644 --- a/tests/test_git_tui.py +++ b/tests/test_git_tui.py @@ -2,9 +2,13 @@ import importlib.machinery import importlib.util +import os import pathlib +import subprocess import sys +import tempfile import unittest +from unittest import mock SCRIPT_PATH = pathlib.Path(__file__).parents[1] / "git-tui" @@ -62,5 +66,163 @@ class GiteaRemoteUrlTests(unittest.TestCase): self.assertEqual(remote_url, "") +class RepositorySyncTests(unittest.TestCase): + def setUp(self) -> None: + self.app = git_tui.GitTuiApp.__new__(git_tui.GitTuiApp) + self.app.status_message = "" + self.app.show_output = lambda *_args: None + self.app.show_command_result = lambda *_args: None + self.original_cwd = pathlib.Path.cwd() + self.temp_dir = tempfile.TemporaryDirectory() + self.root = pathlib.Path(self.temp_dir.name) + + def tearDown(self) -> None: + os.chdir(self.original_cwd) + self.temp_dir.cleanup() + + def git(self, cwd: pathlib.Path, *args: str) -> str: + completed = subprocess.run( + ["git", *args], + cwd=cwd, + check=True, + capture_output=True, + text=True, + ) + return completed.stdout.strip() + + def init_worktree(self, path: pathlib.Path) -> None: + path.mkdir() + self.git(path, "init", "-b", "main") + self.git(path, "config", "user.name", "Test User") + self.git(path, "config", "user.email", "test@example.com") + self.git(path, "config", "commit.gpgsign", "false") + + def test_sync_pushes_local_commit_to_empty_remote(self) -> None: + remote = self.root / "remote.git" + local = self.root / "local" + remote.mkdir() + self.git(remote, "init", "--bare") + self.init_worktree(local) + (local / "local.txt").write_text("local\n", encoding="utf-8") + self.git(local, "add", "local.txt") + self.git(local, "commit", "-m", "local") + self.git(local, "remote", "add", "origin", str(remote)) + + os.chdir(local) + self.assertTrue(self.app.sync_origin_repository()) + self.assertEqual( + self.git(remote, "rev-parse", "refs/heads/main"), + self.git(local, "rev-parse", "HEAD"), + ) + + def test_sync_populates_unborn_local_repository_from_remote(self) -> None: + remote = self.root / "remote.git" + seed = self.root / "seed" + local = self.root / "local" + remote.mkdir() + self.git(remote, "init", "--bare") + self.git(remote, "symbolic-ref", "HEAD", "refs/heads/main") + self.init_worktree(seed) + (seed / "remote.txt").write_text("remote\n", encoding="utf-8") + self.git(seed, "add", "remote.txt") + self.git(seed, "commit", "-m", "remote") + self.git(seed, "remote", "add", "origin", str(remote)) + self.git(seed, "push", "origin", "main") + self.init_worktree(local) + self.git(local, "remote", "add", "origin", str(remote)) + + os.chdir(local) + self.assertTrue(self.app.sync_origin_repository()) + self.assertEqual((local / "remote.txt").read_text(), "remote\n") + self.assertEqual( + self.git(local, "rev-parse", "HEAD"), + self.git(remote, "rev-parse", "refs/heads/main"), + ) + + def test_sync_merges_diverged_histories_and_pushes_result(self) -> None: + remote = self.root / "remote.git" + seed = self.root / "seed" + local = self.root / "local" + remote.mkdir() + self.git(remote, "init", "--bare") + self.git(remote, "symbolic-ref", "HEAD", "refs/heads/main") + self.init_worktree(seed) + (seed / "remote.txt").write_text("remote\n", encoding="utf-8") + self.git(seed, "add", "remote.txt") + self.git(seed, "commit", "-m", "remote") + self.git(seed, "remote", "add", "origin", str(remote)) + self.git(seed, "push", "origin", "main") + self.init_worktree(local) + (local / "local.txt").write_text("local\n", encoding="utf-8") + self.git(local, "add", "local.txt") + self.git(local, "commit", "-m", "local") + self.git(local, "remote", "add", "origin", str(remote)) + self.app.select_option_menu = lambda *_args: "merge" + + os.chdir(local) + self.assertTrue(self.app.sync_origin_repository()) + self.assertTrue((local / "local.txt").exists()) + self.assertTrue((local / "remote.txt").exists()) + self.assertEqual( + self.git(local, "rev-parse", "HEAD"), + self.git(remote, "rev-parse", "refs/heads/main"), + ) + + +class GiteaPublishTests(unittest.TestCase): + def test_created_repository_can_keep_ssh_origin(self) -> None: + app = git_tui.GitTuiApp.__new__(git_tui.GitTuiApp) + app.status_message = "" + prompt_values = iter(["owner/project", "https://git.example.com"]) + app.prompt_input = lambda *_args: next(prompt_values) + app.select_gitea_transport = lambda: "ssh" + app.select_repository_visibility = lambda: False + app.create_gitea_repository = lambda **_kwargs: ( + True, + { + "clone_url": "https://git.example.com/owner/project.git", + "ssh_url": "git@git.example.com:owner/project.git", + "full_name": "owner/project", + }, + "", + ) + configured_urls = [] + app.configure_origin_remote = lambda url: configured_urls.append(url) or True + app.push_head_to_remote_branch = lambda *_args, **_kwargs: True + app.current_local_branch = lambda: "main" + app.show_output = lambda *_args: None + + with mock.patch.dict(os.environ, {"GITEA_TOKEN": "token"}, clear=False): + self.assertTrue(app.publish_gitea_repository()) + + self.assertEqual( + configured_urls, + ["git@git.example.com:owner/project.git"], + ) + + +class MainMenuTests(unittest.TestCase): + def test_remote_operations_and_disconnect_require_a_remote(self) -> None: + app = git_tui.GitTuiApp.__new__(git_tui.GitTuiApp) + app.has_any_remote = lambda: False + + action_names = [action for _, action in app.main_menu_items()] + + self.assertNotIn("push", action_names) + self.assertNotIn("pull", action_names) + self.assertNotIn("fetch", action_names) + self.assertNotIn("disconnect_remote", action_names) + + def test_disconnect_is_available_when_a_remote_exists(self) -> None: + app = git_tui.GitTuiApp.__new__(git_tui.GitTuiApp) + app.has_any_remote = lambda: True + + action_names = [action for _, action in app.main_menu_items()] + + self.assertIn("push", action_names) + self.assertIn("pull", action_names) + self.assertIn("fetch", action_names) + self.assertIn("disconnect_remote", action_names) + if __name__ == "__main__": unittest.main()