Fix Git Flow
This commit is contained in:
+12
-7
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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."""
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
Reference in New Issue
Block a user