Fix Git Flow

This commit is contained in:
2026-08-04 15:53:15 -03:00
parent 8154ca101a
commit 2cdd8c11a3
6 changed files with 720 additions and 62 deletions
+12 -7
View File
@@ -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
+18 -2
View File
@@ -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:
+6 -1
View File
@@ -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
+10 -9
View File
@@ -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 <branch>`).
- 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
+501 -32
View File
@@ -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,6 +1279,31 @@ class GitTuiApp:
self.status_message = "Repository created without clone URL."
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(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,
@@ -909,9 +1312,6 @@ class GitTuiApp:
if not push_username:
self.status_message = "Missing Gitea username for push."
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,
@@ -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."""
+162
View File
@@ -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()