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 # 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. 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.
@@ -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 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 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 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 ## [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: 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, 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: 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 - 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 ## 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:
@@ -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 Git credential helper. You can also paste a complete clone URL to use it
unchanged. 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 ## Keybindings
- Main menu: - Main menu:
+6 -1
View File
@@ -14,10 +14,15 @@
- 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, followed by an SSH or token-authenticated HTTPS push.
- Executes provider-specific connection to existing remote repositories: - Executes provider-specific connection to existing remote repositories:
- GitHub by resolving owner or URL input to `origin`. - 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`. - 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. - Implements file selection for staged and unstaged operations.
## Public API and Contracts ## 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: - If the current directory is not a Git repository, `git-tui` offers setup actions:
- Initialize repository only. - Initialize repository only.
- Initialize and publish to a new remote repository. - Initialize, create a new Gitea remote repository, and sync.
- Initialize and publish flow: - Initialize, connect an existing Gitea remote repository, and sync.
- Chooses publishing provider: GitHub or Gitea. - Create and publish flow:
- Initializes a local Git repository. - Initializes a local Git repository.
- Stages all current files. - Stages all current files.
- Creates an initial commit. - Creates an initial commit.
- GitHub option creates and pushes via `gh repo create --source=. --remote=origin --push`. - Gitea creation uses the HTTPS API token, then configures and pushes `origin` using the separately selected SSH or HTTPS Git transport.
- Gitea option creates via HTTPS API, configures `origin`, and pushes with HTTPS authentication using the provided token.
- Optional Gitea environment variables: - Optional Gitea environment variables:
- `GITEA_URL` or `GITEA_BASE_URL` for instance URL default. - `GITEA_URL` or `GITEA_BASE_URL` for instance URL default.
- `GITEA_OWNER` for default owner or organization. - `GITEA_OWNER` for default owner or organization.
@@ -47,16 +46,18 @@
## Existing Repository Without Remote Flow ## Existing Repository Without Remote Flow
- If the current directory is already a Git repository and has no remotes configured, `git-tui` offers startup actions: - 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. - Continue without remote configuration.
- Quit. - Quit.
- Connect existing remote flow: - 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. - 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. - 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. - 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 ## Key Commands in UI
+501 -32
View File
@@ -163,7 +163,8 @@ class GitTuiApp:
self.set_cursor_visibility(0) self.set_cursor_visibility(0)
self.stdscr.keypad(True) 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() bootstrapped = self.handle_non_repo_startup()
if not bootstrapped: if not bootstrapped:
return return
@@ -173,14 +174,19 @@ 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(): if (
not started_without_repository
and not self.handle_repo_without_remote_startup()
):
return return
selected_index = 0 selected_index = 0
menu_size = len(self.MENU_ITEMS)
while True: 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() key = self.stdscr.getch()
if key in (ord("q"), 27): if key in (ord("q"), 27):
@@ -192,7 +198,7 @@ class GitTuiApp:
selected_index = (selected_index + 1) % menu_size selected_index = (selected_index + 1) % menu_size
continue continue
if key in (10, 13, curses.KEY_ENTER): 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": if action_name == "quit":
return return
self.run_action(action_name) self.run_action(action_name)
@@ -221,8 +227,12 @@ class GitTuiApp:
[ [
("Initialize repository only", "init_only"), ("Initialize repository only", "init_only"),
( (
"Initialize and publish to new remote repo", "Initialize, create Gitea remote, and sync",
"init_publish", "init_create_gitea",
),
(
"Initialize, connect existing Gitea remote, and sync",
"init_connect_gitea",
), ),
("Quit", "quit"), ("Quit", "quit"),
], ],
@@ -234,8 +244,18 @@ class GitTuiApp:
if self.initialize_repository(show_result=True): if self.initialize_repository(show_result=True):
return True return True
continue continue
if action_name == "init_publish": if action_name == "init_create_gitea":
if self.initialize_and_publish_repository(): 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 return True
continue continue
@@ -255,8 +275,12 @@ class GitTuiApp:
"Select startup action", "Select startup action",
[ [
( (
"Connect to an existing remote repository", "Connect existing Gitea remote and sync",
"connect_existing_remote", "connect_existing_gitea",
),
(
"Create Gitea remote and sync (API token required)",
"create_gitea",
), ),
("Continue without remote", "continue_without_remote"), ("Continue without remote", "continue_without_remote"),
("Quit", "quit"), ("Quit", "quit"),
@@ -268,8 +292,14 @@ class GitTuiApp:
if action_name == "continue_without_remote": if action_name == "continue_without_remote":
self.status_message = "Continuing without remote." self.status_message = "Continuing without remote."
return True return True
if action_name == "connect_existing_remote": if action_name == "connect_existing_gitea":
if self.connect_existing_remote_repository(): 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 return True
if self.has_any_remote(): if self.has_any_remote():
return True return True
@@ -350,6 +380,316 @@ class GitTuiApp:
self.status_message = "Connected local repository to origin." self.status_message = "Connected local repository to origin."
return True 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: def prompt_github_existing_remote_url(self) -> str:
"""Prompts for and resolves a GitHub remote URL. """Prompts for and resolves a GitHub remote URL.
@@ -457,17 +797,8 @@ class GitTuiApp:
self.status_message = "Missing Gitea base URL." self.status_message = "Missing Gitea base URL."
return "" return ""
transport = self.select_option_menu( transport = self.select_gitea_transport()
"Gitea clone protocol", if not transport:
"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 ""
remote_url = self.resolve_gitea_remote_url( remote_url = self.resolve_gitea_remote_url(
@@ -487,6 +818,23 @@ class GitTuiApp:
return "" return ""
return remote_url 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( def resolve_gitea_remote_url(
self, self,
repo_input: str, repo_input: str,
@@ -710,6 +1058,31 @@ class GitTuiApp:
self.status_message = "Publishing canceled." self.status_message = "Publishing canceled."
return False 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: def prepare_initial_commit(self, commit_message: str) -> bool:
"""Stages all changes and creates the initial local commit. """Stages all changes and creates the initial local commit.
@@ -813,7 +1186,7 @@ class GitTuiApp:
return True return True
def publish_gitea_repository(self) -> bool: 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: Returns:
True when repository creation and push succeed, otherwise False. True when repository creation and push succeed, otherwise False.
@@ -860,6 +1233,10 @@ class GitTuiApp:
self.status_message = "Missing Gitea base URL." self.status_message = "Missing Gitea base URL."
return False return False
transport = self.select_gitea_transport()
if not transport:
return False
is_private = self.select_repository_visibility() is_private = self.select_repository_visibility()
if is_private is None: if is_private is None:
return False return False
@@ -869,7 +1246,7 @@ class GitTuiApp:
or os.getenv("GITEA_ACCESS_TOKEN", "").strip() or os.getenv("GITEA_ACCESS_TOKEN", "").strip()
) )
if not token: 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: if token is None:
self.status_message = "Missing Gitea API token." self.status_message = "Missing Gitea API token."
return False return False
@@ -887,6 +1264,7 @@ class GitTuiApp:
return False return False
clone_url = str(response_data.get("clone_url") or "").strip() 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() full_name = str(response_data.get("full_name") or "").strip()
if not clone_url: if not clone_url:
if full_name: if full_name:
@@ -901,6 +1279,31 @@ class GitTuiApp:
self.status_message = "Repository created without clone URL." self.status_message = "Repository created without clone URL."
return False 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( push_username = self.resolve_gitea_push_username(
base_url=base_url, base_url=base_url,
token=token, token=token,
@@ -909,9 +1312,6 @@ class GitTuiApp:
if not push_username: if not push_username:
self.status_message = "Missing Gitea username for push." self.status_message = "Missing Gitea username for push."
return False return False
if not self.configure_origin_remote(clone_url):
return False
if not self.push_current_branch_to_origin_with_token( if not self.push_current_branch_to_origin_with_token(
username=push_username, username=push_username,
token=token, token=token,
@@ -925,7 +1325,7 @@ class GitTuiApp:
"Provider: Gitea", "Provider: Gitea",
f"Repository: {full_name or repo_name}", f"Repository: {full_name or repo_name}",
f"Visibility: {visibility_label}", f"Visibility: {visibility_label}",
f"Remote URL: {clone_url}", f"Remote URL: {remote_url}",
] ]
self.show_output("Repository published", "\n".join(summary_lines)) self.show_output("Repository published", "\n".join(summary_lines))
self.status_message = f"Published repository: {full_name or repo_name}" self.status_message = f"Published repository: {full_name or repo_name}"
@@ -1258,11 +1658,36 @@ class GitTuiApp:
return [] return []
return parse_changed_files(output) 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. """Renders the main menu with repository summary information.
Args: Args:
selected_index: Index of the currently highlighted menu item. selected_index: Index of the currently highlighted menu item.
menu_items: Menu entries to render; defaults to current dynamic menu.
""" """
self.stdscr.erase() self.stdscr.erase()
@@ -1277,7 +1702,8 @@ class GitTuiApp:
curses.A_BOLD, 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 " " prefix = ">" if idx == selected_index else " "
row_text = f"{prefix} {label}" row_text = f"{prefix} {label}"
attr = curses.A_REVERSE if idx == selected_index else curses.A_NORMAL attr = curses.A_REVERSE if idx == selected_index else curses.A_NORMAL
@@ -1355,6 +1781,9 @@ class GitTuiApp:
if action_name == "fetch": if action_name == "fetch":
self.action_fetch() self.action_fetch()
return return
if action_name == "disconnect_remote":
self.action_disconnect_remote()
return
if action_name == "status": if action_name == "status":
self.action_status() self.action_status()
return return
@@ -1446,6 +1875,46 @@ class GitTuiApp:
["git", "fetch", "--all", "--prune"], exit_code, output ["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: def action_status(self) -> None:
"""Shows detailed repository status output.""" """Shows detailed repository status output."""
+162
View File
@@ -2,9 +2,13 @@
import importlib.machinery import importlib.machinery
import importlib.util import importlib.util
import os
import pathlib import pathlib
import subprocess
import sys import sys
import tempfile
import unittest import unittest
from unittest import mock
SCRIPT_PATH = pathlib.Path(__file__).parents[1] / "git-tui" SCRIPT_PATH = pathlib.Path(__file__).parents[1] / "git-tui"
@@ -62,5 +66,163 @@ class GiteaRemoteUrlTests(unittest.TestCase):
self.assertEqual(remote_url, "") 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__": if __name__ == "__main__":
unittest.main() unittest.main()