Added a dialog to configure git sync with pass.

This commit is contained in:
Storm Dragon
2025-12-06 05:03:45 -05:00
parent 9d0994c430
commit 1faa691e19

View File

@@ -17,6 +17,7 @@ from gi.repository import Gtk, Gdk, GLib, Atk
import subprocess
import shutil
import os
import re
from pathlib import Path
class PassManager(Gtk.Window):
@@ -63,6 +64,10 @@ class PassManager(Gtk.Window):
# Populate password list
self.populate_password_list()
# Auto-pull from remote if git is configured
if self.isGitRepo and self.has_git_remote():
GLib.idle_add(self.auto_pull_on_startup)
self.show_all()
def show_error_and_exit(self, message):
@@ -140,11 +145,73 @@ class PassManager(Gtk.Window):
gitDir = Path.home() / '.password-store' / '.git'
return gitDir.exists()
def has_git_remote(self):
"""Check if git repository has a remote configured"""
if not self.isGitRepo:
return False
result = subprocess.run(
['git', '-C', str(Path.home() / '.password-store'), 'remote'],
capture_output=True,
text=True
)
return result.returncode == 0 and result.stdout.strip() != ""
def validate_git_url(self, url):
"""Validate git URL format"""
if not url or not url.strip():
return False
url = url.strip()
# Common git URL patterns
patterns = [
r'^git@[\w\.-]+:[\w\-/\.]+\.git$', # git@host:path/repo.git
r'^git@[\w\.-]+:[\w\-/\.]+$', # git@host:path/repo
r'^https?://[\w\.-]+/[\w\-/\.]+\.git$', # https://host/path/repo.git
r'^https?://[\w\.-]+/[\w\-/\.]+$', # https://host/path/repo
r'^ssh://[\w@\.-]+/[\w\-/\.~]+$', # ssh://user@host/path
r'^[\w]+@[\w\.-]+:[\w\-/\.~]+$', # user@host:path
]
for pattern in patterns:
if re.match(pattern, url):
return True
return False
def test_git_url_connectivity(self, url):
"""Test if git URL is accessible"""
result = subprocess.run(
['git', 'ls-remote', url],
capture_output=True,
text=True,
timeout=10
)
return result.returncode == 0
def build_browse_tab(self):
"""Build the browse and view tab"""
browseBox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=10)
self.notebook.append_page(browseBox, Gtk.Label(label="Browse"))
# Git sync info banner (only show if git is not initialized)
if not self.isGitRepo:
infoBanner = Gtk.InfoBar()
infoBanner.set_message_type(Gtk.MessageType.INFO)
contentArea = infoBanner.get_content_area()
infoLabel = Gtk.Label(label="Git sync is not enabled. Passwords are stored locally only.")
contentArea.pack_start(infoLabel, False, False, 0)
setupButton = Gtk.Button(label="Set Up Git Sync")
setupButton.connect("clicked", self.on_setup_git_sync_clicked)
infoBanner.add_action_widget(setupButton, Gtk.ResponseType.NONE)
browseBox.pack_start(infoBanner, False, False, 0)
# Search box
searchBox = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=5)
searchLabel = Gtk.Label(label="_Search:")
@@ -437,7 +504,7 @@ class PassManager(Gtk.Window):
self.gitStatusLabel.set_xalign(0)
gitBox.pack_start(self.gitStatusLabel, False, False, 0)
# Button box
# Button box for sync operations
buttonBox = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=5)
gitBox.pack_start(buttonBox, False, False, 0)
@@ -453,6 +520,14 @@ class PassManager(Gtk.Window):
refreshButton.connect("clicked", self.on_git_refresh_clicked)
buttonBox.pack_start(refreshButton, True, True, 0)
# Button box for configuration
configBox = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=5)
gitBox.pack_start(configBox, False, False, 0)
configRemoteButton = Gtk.Button(label="Configure Remote")
configRemoteButton.connect("clicked", self.on_git_configure_remote_clicked)
configBox.pack_start(configRemoteButton, True, True, 0)
# Log display
logFrame = Gtk.Frame(label="Commit History")
gitBox.pack_start(logFrame, True, True, 0)
@@ -856,9 +931,11 @@ class PassManager(Gtk.Window):
if not self.isGitRepo:
return
passwordStoreDir = str(Path.home() / '.password-store')
# Get current branch
result = subprocess.run(
['git', '-C', str(Path.home() / '.password-store'), 'branch', '--show-current'],
['git', '-C', passwordStoreDir, 'branch', '--show-current'],
capture_output=True,
text=True
)
@@ -866,17 +943,50 @@ class PassManager(Gtk.Window):
# Get status
result = subprocess.run(
['git', '-C', str(Path.home() / '.password-store'), 'status', '--porcelain', '--branch'],
['git', '-C', passwordStoreDir, 'status', '--porcelain', '--branch'],
capture_output=True,
text=True
)
statusText = f"Branch: {branch}"
# Check for uncommitted changes
if result.returncode == 0:
lines = result.stdout.strip().split('\n')
if len(lines) > 1:
statusText += f" ({len(lines) - 1} changes)"
# Check ahead/behind status if remote exists
if self.has_git_remote():
# Fetch to get latest remote status (quietly)
subprocess.run(
['git', '-C', passwordStoreDir, 'fetch'],
capture_output=True,
stderr=subprocess.DEVNULL
)
# Get ahead/behind counts
result = subprocess.run(
['git', '-C', passwordStoreDir, 'rev-list', '--left-right', '--count', f'{branch}...@{{u}}'],
capture_output=True,
text=True
)
if result.returncode == 0:
counts = result.stdout.strip().split()
if len(counts) == 2:
ahead = int(counts[0])
behind = int(counts[1])
if ahead > 0 and behind > 0:
statusText += f" (↑{ahead}{behind})"
elif ahead > 0:
statusText += f" (↑{ahead} ahead)"
elif behind > 0:
statusText += f" (↓{behind} behind)"
else:
statusText += " (up to date)"
self.gitStatusLabel.set_text(statusText)
# Update log
@@ -898,6 +1008,22 @@ class PassManager(Gtk.Window):
else:
self.gitLogBuffer.set_text("Failed to retrieve log")
def auto_pull_on_startup(self):
"""Automatically pull from remote on startup"""
result = subprocess.run(
['pass', 'git', 'pull'],
capture_output=True,
text=True
)
if result.returncode == 0:
# Update UI if pull was successful
self.update_git_status()
self.populate_password_list()
# Silently fail if pull fails - user can manually pull later
return False # Don't repeat this idle callback
def play_sound_effect(self, frequency=800, duration=0.1):
"""Play sound effect"""
if not shutil.which('play'):
@@ -1148,6 +1274,395 @@ class PassManager(Gtk.Window):
"""Handle git refresh button"""
self.update_git_status()
def on_git_configure_remote_clicked(self, button):
"""Handle configure remote button"""
passwordStoreDir = str(Path.home() / '.password-store')
# Get current remote URL if exists
result = subprocess.run(
['git', '-C', passwordStoreDir, 'remote', 'get-url', 'origin'],
capture_output=True,
text=True
)
currentRemote = result.stdout.strip() if result.returncode == 0 else ""
# Show dialog
dialog = Gtk.Dialog(
title="Configure Git Remote",
parent=self,
flags=0
)
dialog.add_buttons(
Gtk.STOCK_CANCEL, Gtk.ResponseType.CANCEL,
Gtk.STOCK_SAVE, Gtk.ResponseType.OK
)
dialog.set_default_size(500, 150)
contentArea = dialog.get_content_area()
contentArea.set_spacing(10)
contentArea.set_border_width(10)
# Instructions
infoLabel = Gtk.Label(label="Enter the URL of your password store git repository:")
infoLabel.set_line_wrap(True)
infoLabel.set_xalign(0)
contentArea.pack_start(infoLabel, False, False, 0)
# Remote URL entry
urlBox = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=5)
urlLabel = Gtk.Label(label="_Remote URL:")
urlLabel.set_use_underline(True)
urlLabel.set_size_request(100, -1)
urlLabel.set_xalign(0)
urlEntry = Gtk.Entry()
urlEntry.set_text(currentRemote)
urlEntry.set_placeholder_text("git@example.com:username/pass-store.git")
urlLabel.set_mnemonic_widget(urlEntry)
urlBox.pack_start(urlLabel, False, False, 0)
urlBox.pack_start(urlEntry, True, True, 0)
contentArea.pack_start(urlBox, False, False, 0)
# Example label
exampleLabel = Gtk.Label(label="Examples:\n • git@github.com:user/pass-store.git\n • https://github.com/user/pass-store.git\n • user@server.com:~/pass-store")
exampleLabel.set_xalign(0)
exampleLabel.set_markup("<small>Examples:\n • git@github.com:user/pass-store.git\n • https://github.com/user/pass-store.git\n • user@server.com:~/pass-store</small>")
contentArea.pack_start(exampleLabel, False, False, 0)
dialog.show_all()
response = dialog.run()
if response == Gtk.ResponseType.OK:
remoteUrl = urlEntry.get_text().strip()
if remoteUrl:
# Remove existing remote if present
subprocess.run(
['git', '-C', passwordStoreDir, 'remote', 'remove', 'origin'],
capture_output=True,
stderr=subprocess.DEVNULL
)
# Add new remote
result = subprocess.run(
['git', '-C', passwordStoreDir, 'remote', 'add', 'origin', remoteUrl],
capture_output=True,
text=True
)
if result.returncode == 0:
self.play_sound_effect(1000, 0.1)
self.update_git_status()
# Show success message
successDialog = Gtk.MessageDialog(
transient_for=self,
flags=0,
message_type=Gtk.MessageType.INFO,
buttons=Gtk.ButtonsType.OK,
text="Remote configured successfully"
)
successDialog.format_secondary_text(
f"Git remote 'origin' set to:\n{remoteUrl}\n\n"
"You can now use Pull and Push buttons to sync."
)
successDialog.run()
successDialog.destroy()
else:
self.show_error(f"Failed to configure remote: {result.stderr}")
dialog.destroy()
def on_setup_git_sync_clicked(self, button):
"""Handle Set Up Git Sync button click"""
# Show explanation dialog
dialog = Gtk.Dialog(
title="Set Up Git Sync",
parent=self,
flags=0
)
dialog.add_buttons(
Gtk.STOCK_CANCEL, Gtk.ResponseType.CANCEL,
"Continue", Gtk.ResponseType.OK
)
dialog.set_default_size(550, 300)
contentArea = dialog.get_content_area()
contentArea.set_spacing(10)
contentArea.set_border_width(10)
# Explanation
explanationText = (
"Git sync allows you to back up and synchronize your passwords across multiple devices.\n\n"
"How it works:\n"
"• Your passwords will be stored in a git repository\n"
"• Each change is automatically committed\n"
"• You can push/pull to sync with a remote server\n"
"• All passwords remain encrypted with your GPG key\n\n"
"You'll need:\n"
"• A git repository URL (GitHub, GitLab, self-hosted, etc.)\n"
"• SSH keys or HTTPS credentials configured for access"
)
explanationLabel = Gtk.Label(label=explanationText)
explanationLabel.set_line_wrap(True)
explanationLabel.set_xalign(0)
contentArea.pack_start(explanationLabel, False, False, 0)
dialog.show_all()
response = dialog.run()
dialog.destroy()
if response != Gtk.ResponseType.OK:
return
# Show URL configuration dialog
self.show_git_url_dialog()
def show_git_url_dialog(self):
"""Show dialog to configure git remote URL"""
dialog = Gtk.Dialog(
title="Configure Git Repository",
parent=self,
flags=0
)
dialog.add_buttons(
Gtk.STOCK_CANCEL, Gtk.ResponseType.CANCEL,
"Set Up", Gtk.ResponseType.OK
)
dialog.set_default_size(550, 250)
contentArea = dialog.get_content_area()
contentArea.set_spacing(10)
contentArea.set_border_width(10)
# Instructions
infoLabel = Gtk.Label(label="Enter your git repository URL:")
infoLabel.set_xalign(0)
contentArea.pack_start(infoLabel, False, False, 0)
# URL entry
urlBox = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=5)
urlLabel = Gtk.Label(label="_Repository URL:")
urlLabel.set_use_underline(True)
urlLabel.set_size_request(120, -1)
urlLabel.set_xalign(0)
urlEntry = Gtk.Entry()
urlEntry.set_placeholder_text("git@github.com:username/pass-store.git")
urlLabel.set_mnemonic_widget(urlEntry)
urlBox.pack_start(urlLabel, False, False, 0)
urlBox.pack_start(urlEntry, True, True, 0)
contentArea.pack_start(urlBox, False, False, 0)
# Test connectivity checkbox
testCheck = Gtk.CheckButton(label="Test connectivity before setup")
testCheck.set_active(True)
contentArea.pack_start(testCheck, False, False, 0)
# Examples
exampleLabel = Gtk.Label()
exampleLabel.set_markup(
"<small><b>Examples:</b>\n"
" • git@github.com:username/pass-store.git\n"
" • https://github.com/username/pass-store.git\n"
" • ssh://user@server.com/~/pass-store.git\n"
" • user@server.com:pass-store</small>"
)
exampleLabel.set_xalign(0)
contentArea.pack_start(exampleLabel, False, False, 0)
# Status label for validation feedback
statusLabel = Gtk.Label()
statusLabel.set_xalign(0)
statusLabel.set_line_wrap(True)
contentArea.pack_start(statusLabel, False, False, 0)
# Make status label accessible as a live region
statusAccessible = statusLabel.get_accessible()
statusAccessible.set_name("Validation status")
dialog.show_all()
statusLabel.hide()
# Helper function to show error and announce to screen reader
def show_status_message(message, messageType='error'):
"""Show status message and make it accessible"""
if messageType == 'error':
statusLabel.set_markup(f"<span foreground='red'>{message}</span>")
elif messageType == 'info':
statusLabel.set_markup(f"<span foreground='blue'>{message}</span>")
elif messageType == 'success':
statusLabel.set_markup(f"<span foreground='green'>{message}</span>")
else:
statusLabel.set_text(message)
statusLabel.show()
# Use Orca's notification system by showing a simple dialog
# This ensures the message is read aloud
if messageType == 'error':
# For errors, show an accessible message dialog briefly
errorDialog = Gtk.MessageDialog(
transient_for=dialog,
flags=Gtk.DialogFlags.MODAL,
message_type=Gtk.MessageType.ERROR,
buttons=Gtk.ButtonsType.OK,
text=message
)
errorDialog.run()
errorDialog.destroy()
urlEntry.grab_focus()
while True:
response = dialog.run()
if response != Gtk.ResponseType.OK:
dialog.destroy()
return
remoteUrl = urlEntry.get_text().strip()
# Validate URL format
if not self.validate_git_url(remoteUrl):
show_status_message("Invalid URL format. Please check the examples above.", 'error')
continue
# Test connectivity if requested
if testCheck.get_active():
# Show testing message
testingDialog = Gtk.MessageDialog(
transient_for=dialog,
flags=Gtk.DialogFlags.MODAL,
message_type=Gtk.MessageType.INFO,
buttons=Gtk.ButtonsType.NONE,
text="Testing connectivity..."
)
testingDialog.show()
# Process events to update UI
while Gtk.events_pending():
Gtk.main_iteration()
try:
connectionSuccess = self.test_git_url_connectivity(remoteUrl)
testingDialog.destroy()
if not connectionSuccess:
show_status_message("Cannot connect to repository. Check URL and network access.", 'error')
continue
except subprocess.TimeoutExpired:
testingDialog.destroy()
show_status_message("Connection timeout. Check URL and network access.", 'error')
continue
except Exception as e:
testingDialog.destroy()
show_status_message(f"Error: {str(e)}", 'error')
continue
# Show success message
successDialog = Gtk.MessageDialog(
transient_for=dialog,
flags=Gtk.DialogFlags.MODAL,
message_type=Gtk.MessageType.INFO,
buttons=Gtk.ButtonsType.OK,
text="Connection successful!"
)
successDialog.format_secondary_text("The repository is accessible and ready to use.")
successDialog.run()
successDialog.destroy()
# URL is valid, proceed with setup
dialog.destroy()
self.initialize_git_sync(remoteUrl)
break
def initialize_git_sync(self, remoteUrl):
"""Initialize git repository and set up remote"""
passwordStoreDir = str(Path.home() / '.password-store')
# Initialize git
result = subprocess.run(
['pass', 'git', 'init'],
capture_output=True,
text=True
)
if result.returncode != 0:
self.show_error(f"Failed to initialize git: {result.stderr}")
return
# Add remote
result = subprocess.run(
['git', '-C', passwordStoreDir, 'remote', 'add', 'origin', remoteUrl],
capture_output=True,
text=True
)
if result.returncode != 0:
self.show_error(f"Failed to add remote: {result.stderr}")
return
# Ask if user wants to push now
pushDialog = Gtk.MessageDialog(
transient_for=self,
flags=0,
message_type=Gtk.MessageType.QUESTION,
buttons=Gtk.ButtonsType.YES_NO,
text="Git sync set up successfully!"
)
pushDialog.format_secondary_text(
f"Repository initialized and remote configured:\n{remoteUrl}\n\n"
"Would you like to push your passwords to the remote now?"
)
response = pushDialog.run()
pushDialog.destroy()
if response == Gtk.ResponseType.YES:
# Push to remote
result = subprocess.run(
['pass', 'git', 'push', '-u', 'origin', 'master'],
capture_output=True,
text=True
)
if result.returncode != 0:
# Try 'main' branch if 'master' fails
result = subprocess.run(
['pass', 'git', 'push', '-u', 'origin', 'main'],
capture_output=True,
text=True
)
if result.returncode != 0:
self.show_error(f"Push failed: {result.stderr}\n\nYou can push manually later from the Git tab.")
else:
self.play_sound_effect(1000, 0.1)
# Update the UI to reflect git is now initialized
self.isGitRepo = True
self.rebuild_ui_with_git()
def rebuild_ui_with_git(self):
"""Rebuild the UI to include the Git tab"""
# Clear existing notebook
for i in range(self.notebook.get_n_pages()):
self.notebook.remove_page(0)
# Rebuild all tabs
self.build_browse_tab()
self.build_add_tab()
self.build_organize_tab()
self.build_git_tab()
# Reconnect signals
self.setup_accessibility()
# Show all and switch to Git tab
self.show_all()
self.notebook.set_current_page(3) # Git tab
# Update git status
self.update_git_status()
if __name__ == "__main__":
win = PassManager()
Gtk.main()