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
+518 -3
View File
@@ -17,6 +17,7 @@ from gi.repository import Gtk, Gdk, GLib, Atk
import subprocess import subprocess
import shutil import shutil
import os import os
import re
from pathlib import Path from pathlib import Path
class PassManager(Gtk.Window): class PassManager(Gtk.Window):
@@ -63,6 +64,10 @@ class PassManager(Gtk.Window):
# Populate password list # Populate password list
self.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() self.show_all()
def show_error_and_exit(self, message): def show_error_and_exit(self, message):
@@ -140,11 +145,73 @@ class PassManager(Gtk.Window):
gitDir = Path.home() / '.password-store' / '.git' gitDir = Path.home() / '.password-store' / '.git'
return gitDir.exists() 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): def build_browse_tab(self):
"""Build the browse and view tab""" """Build the browse and view tab"""
browseBox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=10) browseBox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=10)
self.notebook.append_page(browseBox, Gtk.Label(label="Browse")) 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 # Search box
searchBox = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=5) searchBox = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=5)
searchLabel = Gtk.Label(label="_Search:") searchLabel = Gtk.Label(label="_Search:")
@@ -437,7 +504,7 @@ class PassManager(Gtk.Window):
self.gitStatusLabel.set_xalign(0) self.gitStatusLabel.set_xalign(0)
gitBox.pack_start(self.gitStatusLabel, False, False, 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) buttonBox = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=5)
gitBox.pack_start(buttonBox, False, False, 0) gitBox.pack_start(buttonBox, False, False, 0)
@@ -453,6 +520,14 @@ class PassManager(Gtk.Window):
refreshButton.connect("clicked", self.on_git_refresh_clicked) refreshButton.connect("clicked", self.on_git_refresh_clicked)
buttonBox.pack_start(refreshButton, True, True, 0) 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 # Log display
logFrame = Gtk.Frame(label="Commit History") logFrame = Gtk.Frame(label="Commit History")
gitBox.pack_start(logFrame, True, True, 0) gitBox.pack_start(logFrame, True, True, 0)
@@ -856,9 +931,11 @@ class PassManager(Gtk.Window):
if not self.isGitRepo: if not self.isGitRepo:
return return
passwordStoreDir = str(Path.home() / '.password-store')
# Get current branch # Get current branch
result = subprocess.run( result = subprocess.run(
['git', '-C', str(Path.home() / '.password-store'), 'branch', '--show-current'], ['git', '-C', passwordStoreDir, 'branch', '--show-current'],
capture_output=True, capture_output=True,
text=True text=True
) )
@@ -866,17 +943,50 @@ class PassManager(Gtk.Window):
# Get status # Get status
result = subprocess.run( result = subprocess.run(
['git', '-C', str(Path.home() / '.password-store'), 'status', '--porcelain', '--branch'], ['git', '-C', passwordStoreDir, 'status', '--porcelain', '--branch'],
capture_output=True, capture_output=True,
text=True text=True
) )
statusText = f"Branch: {branch}" statusText = f"Branch: {branch}"
# Check for uncommitted changes
if result.returncode == 0: if result.returncode == 0:
lines = result.stdout.strip().split('\n') lines = result.stdout.strip().split('\n')
if len(lines) > 1: if len(lines) > 1:
statusText += f" ({len(lines) - 1} changes)" 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) self.gitStatusLabel.set_text(statusText)
# Update log # Update log
@@ -898,6 +1008,22 @@ class PassManager(Gtk.Window):
else: else:
self.gitLogBuffer.set_text("Failed to retrieve log") 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): def play_sound_effect(self, frequency=800, duration=0.1):
"""Play sound effect""" """Play sound effect"""
if not shutil.which('play'): if not shutil.which('play'):
@@ -1148,6 +1274,395 @@ class PassManager(Gtk.Window):
"""Handle git refresh button""" """Handle git refresh button"""
self.update_git_status() 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__": if __name__ == "__main__":
win = PassManager() win = PassManager()
Gtk.main() Gtk.main()