diff --git a/scripts/passmanager.py b/scripts/passmanager.py
index 80a3305..5576c99 100755
--- a/scripts/passmanager.py
+++ b/scripts/passmanager.py
@@ -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("Examples:\n • git@github.com:user/pass-store.git\n • https://github.com/user/pass-store.git\n • user@server.com:~/pass-store")
+ 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(
+ "Examples:\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"
+ )
+ 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"{message}")
+ elif messageType == 'info':
+ statusLabel.set_markup(f"{message}")
+ elif messageType == 'success':
+ statusLabel.set_markup(f"{message}")
+ 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()